chua commited on
Commit
bc8e2a9
·
1 Parent(s): 5ce9532

Publish Audio8 ASR ONNX runtime

Browse files
Files changed (45) hide show
  1. .gitattributes +4 -33
  2. .gitignore +13 -0
  3. LICENSE +201 -0
  4. README.md +306 -0
  5. asr_onnx_runtime.py +735 -0
  6. hotword/__init__.py +1 -0
  7. hotword/hotword_trie.py +163 -0
  8. measure_precision_memory.py +147 -0
  9. model_bundle/added_tokens.json +11 -0
  10. model_bundle/audio8_audio_wrapper_config.json +11 -0
  11. model_bundle/audio_hidden.onnx +3 -0
  12. model_bundle/audio_hidden_int8.onnx +3 -0
  13. model_bundle/chat_template.jinja +6 -0
  14. model_bundle/config.json +196 -0
  15. model_bundle/generation_config.json +7 -0
  16. model_bundle/lm_cache_decode.onnx +3 -0
  17. model_bundle/lm_cache_decode_int4.onnx +3 -0
  18. model_bundle/lm_cache_decode_int4.onnx.data +3 -0
  19. model_bundle/lm_cache_decode_int8.onnx +3 -0
  20. model_bundle/lm_cache_decode_int8.onnx.data +3 -0
  21. model_bundle/lm_cache_prefill.onnx +3 -0
  22. model_bundle/lm_cache_prefill_int4.onnx +3 -0
  23. model_bundle/lm_cache_prefill_int4.onnx.data +3 -0
  24. model_bundle/lm_cache_prefill_int8.onnx +3 -0
  25. model_bundle/lm_cache_prefill_int8.onnx.data +3 -0
  26. model_bundle/lm_logits.onnx +3 -0
  27. model_bundle/merges.txt +0 -0
  28. model_bundle/metadata.json +105 -0
  29. model_bundle/preprocessor_config.json +15 -0
  30. model_bundle/processor_config.json +6 -0
  31. model_bundle/qwen3_asr_feature_extractor/preprocessor_config.json +15 -0
  32. model_bundle/special_tokens_map.json +23 -0
  33. model_bundle/tokenizer.json +3 -0
  34. model_bundle/tokenizer_config.json +96 -0
  35. model_bundle/vocab.json +0 -0
  36. model_bundle/weights/audio_projector.npz +3 -0
  37. model_bundle/weights/token_embedding.npy +3 -0
  38. requirements-onnx.txt +11 -0
  39. run_local.sh +28 -0
  40. server.py +359 -0
  41. smoke_test.sh +25 -0
  42. static/app.js +459 -0
  43. static/index.html +121 -0
  44. static/styles.css +391 -0
  45. transcribe_file.py +119 -0
.gitattributes CHANGED
@@ -1,35 +1,6 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
  *.npy filter=lfs diff=lfs merge=lfs -text
15
  *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz 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
 
1
+ *.onnx filter=lfs diff=lfs merge=lfs -text
2
+ *.data filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
3
  *.npy filter=lfs diff=lfs merge=lfs -text
4
  *.npz filter=lfs diff=lfs merge=lfs -text
5
+ *.wav filter=lfs diff=lfs merge=lfs -text
6
+ model_bundle/tokenizer.json filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .DS_Store
2
+ __MACOSX/
3
+ ._*
4
+ __pycache__/
5
+ *.pyc
6
+ *.pyo
7
+ *.log
8
+ *.pid
9
+ .env
10
+ .venv/
11
+ venv/
12
+ dist/
13
+ build/
LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Do not include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
README.md CHANGED
@@ -1,3 +1,309 @@
1
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  license: apache-2.0
 
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ tags:
3
+ - automatic-speech-recognition
4
+ - audio
5
+ - asr
6
+ - onnx
7
+ - onnxruntime
8
+ - quantized
9
+ - int8
10
+ - int4
11
+ language:
12
+ - zh
13
+ library_name: onnx
14
+ pipeline_tag: automatic-speech-recognition
15
  license: apache-2.0
16
+ repository: https://github.com/AutoArk/open-audio-opd
17
  ---
18
+
19
+ <div align="center">
20
+
21
+ # Audio8-ASR-0.1B ONNX Runtime
22
+
23
+ [![GitHub](https://img.shields.io/badge/GitHub-AutoArk%2Fopen--audio--opd-blue?logo=github)](https://github.com/AutoArk/open-audio-opd)
24
+ [![arXiv](https://img.shields.io/badge/arXiv-2605.28139-b31b1b?logo=arxiv)](https://arxiv.org/abs/2605.28139)
25
+ [![License](https://img.shields.io/badge/License-Apache--2.0-green)](https://www.apache.org/licenses/LICENSE-2.0)
26
+
27
+ </div>
28
+
29
+ Audio8-ASR-0.1B ONNX Runtime is a self-contained local inference package for
30
+ Chinese automatic speech recognition. It includes ONNX Runtime inference code,
31
+ a browser UI, a local HTTP API, tokenizer/config files, and decoder/audio-head
32
+ precision variants.
33
+
34
+ This repository does not require the original training repository or a separate
35
+ source checkpoint at runtime. Everything needed for CPU ONNX inference is in
36
+ `model_bundle/`.
37
+
38
+ This repository is intended to be used through the included ONNX Runtime code.
39
+ It is not a Transformers `AutoModel` source release.
40
+
41
+ ## Related Repositories
42
+
43
+ - [Audio8-ASR-0.1B](https://huggingface.co/AutoArk-AI/Audio8-ASR-0.1B): main open-source model repository.
44
+ - [Audio8-ASR-0.1B-iOS-ANE](https://huggingface.co/AutoArk-AI/Audio8-ASR-0.1B-iOS-ANE): iPhone-ready, out-of-the-box ASR demo and Swift SDK. The demo is designed to keep runtime memory footprint around 200 MB on device.
45
+ - [AutoArk/open-audio-opd](https://github.com/AutoArk/open-audio-opd): shared GitHub project for Audio8 open-source releases.
46
+
47
+ ## Contents
48
+
49
+ - `model_bundle/`: tokenizer, feature extractor config, ONNX graphs, and numpy weights.
50
+ - `asr_onnx_runtime.py`: ONNX Runtime ASR engine.
51
+ - `server.py`: FastAPI local web/API server.
52
+ - `static/`: browser UI with file upload, microphone recording, precision switching, hotwords, and memory panels.
53
+ - `transcribe_file.py`: single-file CLI and minimal Python helper.
54
+ - `hotword/`: optional decode-time hotword trie boosting helper.
55
+ - `run_local.sh`: local WebUI launch helper.
56
+ - `smoke_test.sh`: health + ASR API smoke test for a user-provided audio file.
57
+ - `measure_precision_memory.py`: optional fresh-process RSS measurement helper.
58
+
59
+ ## Included ONNX Variants
60
+
61
+ Decoder cache graphs:
62
+
63
+ - `fp32`: `lm_cache_prefill.onnx`, `lm_cache_decode.onnx`
64
+ - `int8`: `lm_cache_prefill_int8.onnx`, `lm_cache_decode_int8.onnx`
65
+ - `int4`: `lm_cache_prefill_int4.onnx`, `lm_cache_decode_int4.onnx`
66
+
67
+ Audio tower graphs:
68
+
69
+ - `fp32`: `audio_hidden.onnx`
70
+ - `int8`: `audio_hidden_int8.onnx`
71
+
72
+ The default runtime path is decoder `int8` plus audio tower `int8`. Decoder
73
+ `int4` is included for lower peak memory, while decoder `fp32` is included as a
74
+ full-precision reference path.
75
+
76
+ ## Install
77
+
78
+ Use Python 3.10+. Python 3.12 is recommended.
79
+
80
+ ```bash
81
+ python3.12 -m venv .venv
82
+ source .venv/bin/activate
83
+ python3 -m pip install --upgrade pip
84
+ python3 -m pip install -r requirements-onnx.txt
85
+ ```
86
+
87
+ With `uv`:
88
+
89
+ ```bash
90
+ uv venv --python 3.12 .venv
91
+ uv pip install --python .venv/bin/python -r requirements-onnx.txt
92
+ source .venv/bin/activate
93
+ ```
94
+
95
+ With conda:
96
+
97
+ ```bash
98
+ conda create -n audio8-asr-onnx python=3.12
99
+ conda activate audio8-asr-onnx
100
+ python3 -m pip install -r requirements-onnx.txt
101
+ ```
102
+
103
+ ## Run WebUI
104
+
105
+ ```bash
106
+ ./run_local.sh
107
+ ```
108
+
109
+ Open:
110
+
111
+ ```text
112
+ http://127.0.0.1:7860
113
+ ```
114
+
115
+ If the port is busy:
116
+
117
+ ```bash
118
+ PORT=7870 ./run_local.sh
119
+ ```
120
+
121
+ ## Command Line
122
+
123
+ Transcribe one local audio file without starting the WebUI:
124
+
125
+ ```bash
126
+ python3 transcribe_file.py /path/to/audio.wav --max_new_tokens 128
127
+ ```
128
+
129
+ Print the full result JSON:
130
+
131
+ ```bash
132
+ python3 transcribe_file.py /path/to/audio.wav --json
133
+ ```
134
+
135
+ Force a precision combination:
136
+
137
+ ```bash
138
+ python3 transcribe_file.py /path/to/audio.wav \
139
+ --cache_precision int8 \
140
+ --audio_precision int8
141
+ ```
142
+
143
+ Enable optional hotword biasing:
144
+
145
+ ```bash
146
+ python3 transcribe_file.py /path/to/audio.wav \
147
+ --hotwords "term_one,term_two" \
148
+ --json
149
+ ```
150
+
151
+ ## Use From Python
152
+
153
+ ```python
154
+ from pathlib import Path
155
+
156
+ from asr_onnx_runtime import OnnxCacheAsrEngine
157
+
158
+
159
+ engine = OnnxCacheAsrEngine(
160
+ "model_bundle",
161
+ cache_precision="int8",
162
+ audio_precision="int8",
163
+ )
164
+ result = engine.transcribe(
165
+ Path("/path/to/audio.wav").read_bytes(),
166
+ language="Chinese",
167
+ max_new_tokens=128,
168
+ hotwords=None,
169
+ )
170
+ print(result["text"])
171
+ ```
172
+
173
+ The lower-level `OnnxAsrEngine` class is available for the full-context
174
+ fallback graph. Prefer `OnnxCacheAsrEngine` for normal local inference.
175
+
176
+ ## HTTP API
177
+
178
+ Start the server with `./run_local.sh`, then call `POST /asr` with multipart
179
+ form data:
180
+
181
+ ```bash
182
+ curl --noproxy "*" -fsS -X POST http://127.0.0.1:7860/asr \
183
+ -F "audio=@/path/to/audio.wav" \
184
+ -F "language=Chinese" \
185
+ -F "max_new_tokens=128" \
186
+ -F "cache_precision=int8" \
187
+ -F "audio_precision=int8" \
188
+ | python3 -m json.tool
189
+ ```
190
+
191
+ Form fields:
192
+
193
+ - `audio`: required audio file. WAV is recommended; `librosa`/`soundfile` handle common formats.
194
+ - `language`: optional, currently intended for `Chinese`.
195
+ - `max_new_tokens`: optional generation cap; default is `128`.
196
+ - `cache_precision`: optional decoder precision, one of `fp32`, `int8`, `int4`, `auto`.
197
+ - `audio_precision`: optional audio tower precision, one of `fp32`, `int8`, `auto`.
198
+ - `hotwords`: optional comma-separated hotwords. Omit or leave empty to disable.
199
+ - `hotword_topk`: optional top-k gate for applying boosts; default is `50`.
200
+ - `hotword_start_boost`: optional first-token boost; default is `6.0`.
201
+ - `hotword_continuation_boost`: optional continuation-token boost; default is `8.0`.
202
+
203
+ Useful endpoints:
204
+
205
+ - `GET /health`: readiness and selected runtime.
206
+ - `GET /api/runtime`: selected graphs, provider, and available precision variants.
207
+ - `POST /api/reload`: switch backend/precision without restarting the process.
208
+ - `GET /metrics`: process/system memory metrics plus runtime info.
209
+
210
+ Important response fields:
211
+
212
+ - `text`: normalized transcript for application use.
213
+ - `raw`: raw decoded model text before normalization.
214
+ - `elapsed_seconds`: inference time inside the runtime.
215
+ - `audio_seconds`: decoded audio duration after loading/resampling.
216
+ - `generated_tokens`, `hit_stop`, `stop_token_id`: generation diagnostics.
217
+ - `backend`, `cache_precision`, `audio_precision`, `providers`: selected runtime path.
218
+ - `request_peak_rss_bytes`: latest request RSS high-water mark.
219
+ - `hotword`: hotword tokenization/boost metadata when hotwords are enabled, otherwise `null`.
220
+
221
+ ## Hotwords
222
+
223
+ Hotwords are an opt-in decode-time feature. They do not change model weights,
224
+ ONNX graphs, or the prompt. The runtime tokenizes each hotword with the bundled
225
+ tokenizer, builds a prefix trie, and adds a top-k gated logit boost during
226
+ decoding. If no hotwords are provided, the decode path is unchanged except that
227
+ the response includes `"hotword": null`.
228
+
229
+ The WebUI exposes two hotword strength levels:
230
+
231
+ - `Normal`: default logit boost.
232
+ - `Strong`: stronger biasing for difficult names or rare terms.
233
+
234
+ Strong hotword biasing may force incorrect hotwords, hallucinate, or repeat
235
+ text. Use it only when the target terms are known in advance.
236
+
237
+ ## Runtime Defaults
238
+
239
+ ```text
240
+ ASR_BACKEND=auto
241
+ ASR_CACHE_PRECISION=int8
242
+ ASR_AUDIO_PRECISION=int8
243
+ ```
244
+
245
+ Available variants:
246
+
247
+ - decoder: `fp32`, `int8`, `int4`
248
+ - audio tower: `fp32`, `int8`
249
+
250
+ Force a specific combination:
251
+
252
+ ```bash
253
+ ASR_BACKEND=onnx_cache ASR_CACHE_PRECISION=fp32 ASR_AUDIO_PRECISION=fp32 ./run_local.sh
254
+ ASR_BACKEND=onnx_cache ASR_CACHE_PRECISION=int8 ASR_AUDIO_PRECISION=int8 ./run_local.sh
255
+ ASR_BACKEND=onnx_cache ASR_CACHE_PRECISION=int4 ASR_AUDIO_PRECISION=int8 ./run_local.sh
256
+ ```
257
+
258
+ ## Runtime Limits
259
+
260
+ - Audio is loaded as mono and resampled to 16 kHz.
261
+ - Audio longer than 30 seconds is truncated by the runtime bundle metadata.
262
+ - Cached decoder context is capped at 512 total tokens. If prompt audio tokens
263
+ plus `max_new_tokens` exceed that limit, the runtime raises an error.
264
+ - CPU ONNX Runtime is the verified default path. GPU use requires installing a
265
+ compatible ONNX Runtime GPU package and selecting an available provider.
266
+
267
+ ## License
268
+
269
+ This project is released under the Apache License 2.0. See `LICENSE`.
270
+
271
+ ## Notes
272
+
273
+ - `requirements-onnx.txt` is pinned for reproducible local behavior.
274
+ - Runtime audio loading tries `librosa.load` first for consistent decoding.
275
+ - `run_local.sh` sets `NO_PROXY/no_proxy` for localhost inside the service
276
+ process only; it does not change system proxy settings.
277
+ - Browser recording uploads WAV/RIFF audio. The UI records PCM with Web Audio,
278
+ waits a short flush after Stop, then appends silence before encoding WAV.
279
+ - The UI memory panels report process RSS for CPU ONNX inference. `Peak RSS` is
280
+ the service high-water mark; `Request Peak` is the latest request peak.
281
+
282
+ ## Quick Checks
283
+
284
+ Syntax/import check:
285
+
286
+ ```bash
287
+ python3 -m py_compile \
288
+ asr_onnx_runtime.py \
289
+ server.py \
290
+ measure_precision_memory.py \
291
+ transcribe_file.py
292
+ ```
293
+
294
+ API smoke test with your own audio file:
295
+
296
+ ```bash
297
+ ./run_local.sh
298
+ ./smoke_test.sh 127.0.0.1 7860 /path/to/audio.wav
299
+ ```
300
+
301
+ Run one precision memory measurement:
302
+
303
+ ```bash
304
+ python3 measure_precision_memory.py \
305
+ --bundle_dir model_bundle \
306
+ --audio /path/to/audio.wav \
307
+ --cache_precision int8 \
308
+ --audio_precision int8
309
+ ```
asr_onnx_runtime.py ADDED
@@ -0,0 +1,735 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import json
5
+ import math
6
+ import os
7
+ import re
8
+ import subprocess
9
+ import time
10
+ import wave
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import numpy as np
15
+ import onnxruntime as ort
16
+ import psutil
17
+ from tokenizers import Tokenizer
18
+ from transformers import WhisperFeatureExtractor
19
+
20
+ from hotword.hotword_trie import build_trie_from_hotwords, parse_hotwords
21
+
22
+
23
+ SPECIAL_TOKEN_PATTERN = re.compile(
24
+ r"<\|(?:"
25
+ r"bicodec_(?:semantic|global)_\d+|"
26
+ r"(?:start|end)_(?:global_token|glm_token|semantic_token|content)|"
27
+ r"[^>]+"
28
+ r")\|>"
29
+ )
30
+ TURN_END_MARKERS = ("<|user|>", "<|assistant|>", "<|im_end|>")
31
+ LEADING_NOISE_PATTERN = re.compile(r"^[\s,.;:!?-]+")
32
+
33
+
34
+ def _resample_linear(audio: np.ndarray, orig_sr: int, target_sr: int) -> np.ndarray:
35
+ if int(orig_sr) == int(target_sr):
36
+ return audio.astype(np.float32, copy=False)
37
+ if audio.size == 0:
38
+ return audio.astype(np.float32, copy=False)
39
+ duration = float(audio.shape[0]) / float(orig_sr)
40
+ target_len = max(1, int(round(duration * float(target_sr))))
41
+ old_x = np.linspace(0.0, duration, num=audio.shape[0], endpoint=False)
42
+ new_x = np.linspace(0.0, duration, num=target_len, endpoint=False)
43
+ return np.interp(new_x, old_x, audio).astype(np.float32, copy=False)
44
+
45
+
46
+ def load_audio_bytes(audio_bytes: bytes, sampling_rate: int) -> np.ndarray:
47
+ try:
48
+ import librosa
49
+
50
+ audio, _ = librosa.load(io.BytesIO(audio_bytes), sr=int(sampling_rate), mono=True)
51
+ return np.asarray(audio, dtype=np.float32)
52
+ except Exception:
53
+ pass
54
+
55
+ try:
56
+ import soundfile as sf
57
+
58
+ audio, sr = sf.read(io.BytesIO(audio_bytes), dtype="float32", always_2d=False)
59
+ if audio.ndim > 1:
60
+ audio = audio.mean(axis=-1)
61
+ return _resample_linear(np.asarray(audio, dtype=np.float32), int(sr), int(sampling_rate))
62
+ except Exception:
63
+ pass
64
+
65
+ with wave.open(io.BytesIO(audio_bytes), "rb") as wav:
66
+ sr = int(wav.getframerate())
67
+ channels = int(wav.getnchannels())
68
+ sample_width = int(wav.getsampwidth())
69
+ raw = wav.readframes(wav.getnframes())
70
+ if sample_width != 2:
71
+ raise ValueError(f"Fallback wave loader only supports 16-bit PCM WAV, got sample_width={sample_width}")
72
+ audio = np.frombuffer(raw, dtype="<i2").astype(np.float32) / 32768.0
73
+ if channels > 1:
74
+ audio = audio.reshape(-1, channels).mean(axis=-1)
75
+ return _resample_linear(audio, sr, int(sampling_rate))
76
+
77
+
78
+ def truncate_generation_text(text: str) -> str:
79
+ cut = len(text)
80
+ for marker in TURN_END_MARKERS:
81
+ index = text.find(marker)
82
+ if index != -1 and index < cut:
83
+ cut = index
84
+ return text[:cut].strip()
85
+
86
+
87
+ def normalize_prediction_text(text: str) -> str:
88
+ if not text:
89
+ return ""
90
+ text = truncate_generation_text(text)
91
+ if "<|text|>" in text:
92
+ text = text.split("<|text|>", 1)[1]
93
+ if "<asr_text>" in text:
94
+ text = text.split("<asr_text>", 1)[1]
95
+ text = re.sub(r"^\s*language\s+[A-Za-z]+\s+", "", text)
96
+ text = SPECIAL_TOKEN_PATTERN.sub("", text).strip()
97
+ text = re.sub(r"\s+", " ", text).strip()
98
+ return LEADING_NOISE_PATTERN.sub("", text).strip()
99
+
100
+
101
+ def create_ort_session_options(intra_op_num_threads: int | None = None) -> ort.SessionOptions:
102
+ options = ort.SessionOptions()
103
+ if intra_op_num_threads is not None and int(intra_op_num_threads) > 0:
104
+ options.intra_op_num_threads = int(intra_op_num_threads)
105
+ options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
106
+ if os.environ.get("ORT_CPU_MEM_ARENA", "0").lower() not in {"1", "true", "yes", "on"}:
107
+ options.enable_cpu_mem_arena = False
108
+ if os.environ.get("ORT_MEM_PATTERN", "0").lower() not in {"1", "true", "yes", "on"}:
109
+ options.enable_mem_pattern = False
110
+ return options
111
+
112
+
113
+ def ark_audio_token_count(sample_count: int, *, hop_length: int, merge_factor: int) -> int:
114
+ mel_frames = int(sample_count) // max(int(hop_length), 1)
115
+ downsampled = (int(mel_frames) + 1) // 2
116
+ merged = downsampled // max(int(merge_factor), 1)
117
+ return max(int(merged), 1)
118
+
119
+
120
+ def layer_norm(x: np.ndarray, weight: np.ndarray, bias: np.ndarray, eps: float = 1e-5) -> np.ndarray:
121
+ x32 = x.astype(np.float32, copy=False)
122
+ mean = x32.mean(axis=-1, keepdims=True)
123
+ var = ((x32 - mean) ** 2).mean(axis=-1, keepdims=True)
124
+ return ((x32 - mean) / np.sqrt(var + eps)) * weight + bias
125
+
126
+
127
+ def adaptive_avg_pool_time(x: np.ndarray, output_size: int) -> np.ndarray:
128
+ input_size = int(x.shape[0])
129
+ output_size = int(output_size)
130
+ if input_size == output_size:
131
+ return x.astype(np.float32, copy=False)
132
+ pooled = np.empty((output_size, x.shape[1]), dtype=np.float32)
133
+ for out_i in range(output_size):
134
+ start = int(math.floor(out_i * input_size / output_size))
135
+ end = int(math.ceil((out_i + 1) * input_size / output_size))
136
+ end = max(end, start + 1)
137
+ pooled[out_i] = x[start:end].mean(axis=0)
138
+ return pooled
139
+
140
+
141
+ def apply_repetition_penalty(logits: np.ndarray, token_ids: list[int], penalty: float) -> np.ndarray:
142
+ if penalty == 1.0:
143
+ return logits
144
+ for token_id in set(int(value) for value in token_ids):
145
+ if 0 <= token_id < logits.shape[-1]:
146
+ logits[token_id] = logits[token_id] * penalty if logits[token_id] < 0 else logits[token_id] / penalty
147
+ return logits
148
+
149
+
150
+ def softmax(x: np.ndarray) -> np.ndarray:
151
+ shifted = x - np.max(x)
152
+ exp = np.exp(shifted)
153
+ return exp / np.sum(exp)
154
+
155
+
156
+ class OnnxAsrEngine:
157
+ def __init__(
158
+ self,
159
+ bundle_dir: str | Path,
160
+ *,
161
+ provider: str = "CPUExecutionProvider",
162
+ intra_op_num_threads: int | None = None,
163
+ load_lm_session: bool = True,
164
+ audio_precision: str = "fp32",
165
+ ) -> None:
166
+ self.bundle_dir = Path(bundle_dir).expanduser().resolve()
167
+ with (self.bundle_dir / "metadata.json").open("r", encoding="utf-8") as handle:
168
+ self.metadata = json.load(handle)
169
+ self.audio_precision = str(audio_precision or "fp32").lower().strip()
170
+
171
+ options = create_ort_session_options(intra_op_num_threads)
172
+ providers = [provider] if provider in ort.get_available_providers() else ["CPUExecutionProvider"]
173
+ if "CPUExecutionProvider" not in providers:
174
+ providers.append("CPUExecutionProvider")
175
+ audio_graph_key = "audio_hidden"
176
+ if self.audio_precision in {"int8", "auto"} and "audio_hidden_int8" in self.metadata.get("graphs", {}):
177
+ audio_graph_key = "audio_hidden_int8"
178
+ self.audio_precision = "int8"
179
+ else:
180
+ self.audio_precision = "fp32"
181
+ self.audio_graph = self.metadata["graphs"][audio_graph_key]
182
+ self.audio_graph_path = self.bundle_dir / self.audio_graph["path"]
183
+
184
+ self.audio_session = ort.InferenceSession(
185
+ str(self.audio_graph_path),
186
+ sess_options=options,
187
+ providers=providers,
188
+ )
189
+ self.lm_session = None
190
+ if load_lm_session:
191
+ self.lm_session = ort.InferenceSession(
192
+ str(self.bundle_dir / self.metadata["graphs"]["lm_logits"]["path"]),
193
+ sess_options=options,
194
+ providers=providers,
195
+ )
196
+ self.providers = {
197
+ "audio": self.audio_session.get_providers(),
198
+ "lm": self.lm_session.get_providers() if self.lm_session is not None else None,
199
+ }
200
+
201
+ self.tokenizer = Tokenizer.from_file(str(self.bundle_dir / "tokenizer.json"))
202
+ feature_dir = self.bundle_dir / "qwen3_asr_feature_extractor"
203
+ if not feature_dir.exists():
204
+ feature_dir = self.bundle_dir
205
+ self.feature_extractor = WhisperFeatureExtractor.from_pretrained(str(feature_dir))
206
+ self.token_embedding = np.load(
207
+ self.bundle_dir / self.metadata["weights"]["token_embedding"],
208
+ mmap_mode="r",
209
+ )
210
+ if self.token_embedding.dtype != np.float32:
211
+ self.token_embedding = self.token_embedding.astype(np.float32, copy=False)
212
+ projector = np.load(self.bundle_dir / self.metadata["weights"]["audio_projector"])
213
+ self.projector = {key: projector[key].astype(np.float32) for key in projector.files}
214
+
215
+ tokens = self.metadata["tokens"]
216
+ self.audio_token_id = int(tokens["audio_token_id"])
217
+ self.pad_token_id = int(tokens["pad_token_id"])
218
+ self.eos_token_ids = set(int(value) for value in tokens["eos_token_ids"])
219
+ self.asr_block_token_id_from = int(tokens.get("asr_block_token_id_from", -1))
220
+ self.extra_block_token_ids = set(int(value) for value in tokens.get("extra_block_token_ids", []))
221
+ self.sampling_rate = int(self.metadata["sampling_rate"])
222
+ self.max_audio_seconds = int(self.metadata["max_audio_seconds"])
223
+ prompt_audio = self.metadata.get("prompt_audio", {})
224
+ self.prompt_merge_factor = int(prompt_audio.get("merge_factor") or 4)
225
+
226
+ def _token_to_id(self, token: str) -> int:
227
+ token_id = self.tokenizer.token_to_id(token)
228
+ if token_id is None:
229
+ raise KeyError(f"Token not found in tokenizer: {token}")
230
+ return int(token_id)
231
+
232
+ def _build_prompt(self, audio_token_count: int, language: str | None = None) -> str:
233
+ del language
234
+ tokens = self.metadata["tokens"]
235
+ audio_tokens = tokens["audio_token"] * int(audio_token_count)
236
+ return (
237
+ f"{tokens['user_token']}"
238
+ f"{tokens['bos_audio_token']}{audio_tokens}{tokens['eos_audio_token']}"
239
+ "Please transcribe this audio."
240
+ f"{tokens['assistant_token']}"
241
+ f"{self.metadata.get('response_prefix', '') or ''}"
242
+ )
243
+
244
+ def _extract_features(self, audio: np.ndarray) -> tuple[np.ndarray, int, int, int]:
245
+ max_samples = int(self.max_audio_seconds * self.sampling_rate)
246
+ if audio.shape[0] > max_samples:
247
+ audio = audio[:max_samples]
248
+ sample_count = int(max(1, audio.shape[0]))
249
+ feature = self.feature_extractor(
250
+ [audio],
251
+ sampling_rate=self.sampling_rate,
252
+ return_tensors="np",
253
+ return_attention_mask=False,
254
+ padding="longest",
255
+ max_length=max_samples,
256
+ )["input_features"].astype(np.float32)
257
+ hop_length = int(getattr(self.feature_extractor, "hop_length", 160))
258
+ encoder_feature_len = int(math.ceil(float(sample_count) / float(max(hop_length, 1))))
259
+ encoder_feature_len = min(max(1, encoder_feature_len), int(feature.shape[-1]))
260
+
261
+ frames_padded = int(self.audio_graph["frames_padded"])
262
+ if feature.shape[-1] < frames_padded:
263
+ feature = np.pad(feature, ((0, 0), (0, 0), (0, frames_padded - feature.shape[-1])), mode="constant")
264
+ elif feature.shape[-1] > frames_padded:
265
+ feature = feature[:, :, :frames_padded]
266
+ return feature.astype(np.float32), sample_count, encoder_feature_len, hop_length
267
+
268
+ def _audio_embeddings(
269
+ self,
270
+ feature: np.ndarray,
271
+ sample_count: int,
272
+ encoder_feature_len: int,
273
+ hop_length: int,
274
+ ) -> np.ndarray:
275
+ audio_token_count = ark_audio_token_count(
276
+ sample_count,
277
+ hop_length=hop_length,
278
+ merge_factor=self.prompt_merge_factor,
279
+ )
280
+ hidden, valid_mask = self.audio_session.run(
281
+ None,
282
+ {
283
+ "audios": feature.astype(np.float32, copy=False),
284
+ "audio_feature_lengths": np.asarray([encoder_feature_len], dtype=np.int64),
285
+ },
286
+ )
287
+ hidden = hidden.astype(np.float32, copy=False)
288
+ valid_mask = valid_mask.astype(bool)
289
+ valid_hidden = hidden[valid_mask]
290
+ if valid_hidden.shape[0] != audio_token_count:
291
+ valid_hidden = adaptive_avg_pool_time(valid_hidden, audio_token_count)
292
+ projected = layer_norm(
293
+ valid_hidden,
294
+ self.projector["norm_weight"],
295
+ self.projector["norm_bias"],
296
+ )
297
+ projected = projected @ self.projector["linear_weight"].T + self.projector["linear_bias"]
298
+ return projected.astype(np.float32, copy=False)
299
+
300
+ def _initial_embeddings(self, audio_embeddings: np.ndarray, language: str | None) -> tuple[list[int], np.ndarray]:
301
+ prompt = self._build_prompt(audio_embeddings.shape[0], language=language)
302
+ input_ids = self.tokenizer.encode(prompt, add_special_tokens=False).ids
303
+ embeds = self.token_embedding[np.asarray(input_ids, dtype=np.int64)].astype(np.float32)
304
+ audio_positions = [index for index, token_id in enumerate(input_ids) if int(token_id) == self.audio_token_id]
305
+ if len(audio_positions) != audio_embeddings.shape[0]:
306
+ raise RuntimeError(
307
+ f"Prompt has {len(audio_positions)} audio tokens, but audio graph returned {audio_embeddings.shape[0]}"
308
+ )
309
+ embeds[np.asarray(audio_positions, dtype=np.int64)] = audio_embeddings
310
+ return [int(value) for value in input_ids], embeds
311
+
312
+ def _mask_logits(self, logits: np.ndarray) -> None:
313
+ if self.asr_block_token_id_from >= 0 and self.asr_block_token_id_from < logits.shape[0]:
314
+ logits[self.asr_block_token_id_from :] = -np.inf
315
+ for token_id in self.extra_block_token_ids:
316
+ if 0 <= token_id < logits.shape[0]:
317
+ logits[token_id] = -np.inf
318
+
319
+ def _build_hotword_trie(self, hotwords, start_boost: float, continuation_boost: float):
320
+ special_ids = set(self.eos_token_ids)
321
+ special_ids.add(self.pad_token_id)
322
+ special_ids.update(self.extra_block_token_ids)
323
+
324
+ def encode(text: str) -> list[int]:
325
+ return list(self.tokenizer.encode(text, add_special_tokens=False).ids)
326
+
327
+ def id_to_token(token_id: int) -> str:
328
+ return str(self.tokenizer.id_to_token(int(token_id)) or "")
329
+
330
+ trie, sequences_by_word = build_trie_from_hotwords(
331
+ hotwords,
332
+ encode=encode,
333
+ id_to_token=id_to_token,
334
+ special_ids=special_ids,
335
+ start_boost=float(start_boost),
336
+ continuation_boost=float(continuation_boost),
337
+ )
338
+ meta = {
339
+ "hotwords": list(hotwords),
340
+ "hotword_token_ids": {word: variants for word, variants in sequences_by_word.items()},
341
+ "hotword_start_boost": float(start_boost),
342
+ "hotword_continuation_boost": float(continuation_boost),
343
+ }
344
+ return trie, meta
345
+
346
+ @staticmethod
347
+ def _apply_hotword_boost(logits: np.ndarray, generated: list[int], trie, topk: int) -> None:
348
+ if not trie:
349
+ return
350
+ boosts = trie.boosts_for_generated(generated)
351
+ if not boosts:
352
+ return
353
+ allowed: set[int] | None = None
354
+ if topk and int(topk) > 0:
355
+ k = min(int(topk), int(logits.shape[-1]))
356
+ allowed = set(int(i) for i in np.argpartition(logits, -k)[-k:])
357
+ vocab = int(logits.shape[-1])
358
+ for token_id, boost in boosts.items():
359
+ if allowed is not None and token_id not in allowed:
360
+ continue
361
+ if 0 <= token_id < vocab:
362
+ logits[token_id] += boost
363
+
364
+ def transcribe(
365
+ self,
366
+ audio_bytes: bytes,
367
+ *,
368
+ language: str | None = None,
369
+ max_new_tokens: int = 128,
370
+ temperature: float = 0.5,
371
+ repetition_penalty: float = 1.0,
372
+ do_sample: bool = False,
373
+ hotwords: str | list | None = None,
374
+ hotword_topk: int = 50,
375
+ hotword_start_boost: float = 6.0,
376
+ hotword_continuation_boost: float = 8.0,
377
+ ) -> dict[str, Any]:
378
+ started = time.perf_counter()
379
+ audio = load_audio_bytes(audio_bytes, self.sampling_rate)
380
+ feature, sample_count, encoder_feature_len, hop_length = self._extract_features(audio)
381
+ audio_embeddings = self._audio_embeddings(feature, sample_count, encoder_feature_len, hop_length)
382
+ token_ids, embeds = self._initial_embeddings(audio_embeddings, language=language)
383
+ hotword_list = parse_hotwords(hotwords)
384
+ hot_trie, hot_meta = (
385
+ self._build_hotword_trie(hotword_list, hotword_start_boost, hotword_continuation_boost)
386
+ if hotword_list
387
+ else (None, None)
388
+ )
389
+
390
+ generated: list[int] = []
391
+ hit_stop = False
392
+ stop_token_id: int | None = None
393
+ rng = np.random.default_rng()
394
+ for _ in range(int(max_new_tokens)):
395
+ if self.lm_session is None:
396
+ raise RuntimeError("ONNX LM session is not loaded")
397
+ attention_mask = np.ones((1, embeds.shape[0]), dtype=np.int64)
398
+ logits = self.lm_session.run(
399
+ None,
400
+ {
401
+ "inputs_embeds": embeds[None, :, :].astype(np.float32, copy=False),
402
+ "attention_mask": attention_mask,
403
+ },
404
+ )[0][0].astype(np.float32)
405
+ apply_repetition_penalty(logits, token_ids + generated, float(repetition_penalty))
406
+ self._mask_logits(logits)
407
+ if hot_trie:
408
+ self._apply_hotword_boost(logits, generated, hot_trie, hotword_topk)
409
+ if do_sample:
410
+ probs = softmax(logits / max(float(temperature), 1e-6))
411
+ next_token = int(rng.choice(np.arange(probs.shape[0]), p=probs))
412
+ else:
413
+ next_token = int(np.argmax(logits))
414
+ if next_token in self.eos_token_ids or next_token == self.pad_token_id:
415
+ hit_stop = True
416
+ stop_token_id = next_token
417
+ break
418
+ generated.append(next_token)
419
+ token_embed = self.token_embedding[np.asarray([next_token], dtype=np.int64)].astype(np.float32)
420
+ embeds = np.concatenate([embeds, token_embed], axis=0)
421
+
422
+ raw = self.tokenizer.decode(generated, skip_special_tokens=False)
423
+ text = normalize_prediction_text(raw)
424
+ elapsed = time.perf_counter() - started
425
+ return {
426
+ "text": text,
427
+ "raw": raw,
428
+ "generated_tokens": len(generated),
429
+ "hit_stop": hit_stop,
430
+ "stop_token_id": stop_token_id,
431
+ "elapsed_seconds": elapsed,
432
+ "audio_seconds": float(audio.shape[0]) / float(self.sampling_rate),
433
+ "audio_token_count": int(audio_embeddings.shape[0]),
434
+ "providers": self.providers,
435
+ "backend": "onnx",
436
+ "audio_precision": self.audio_precision,
437
+ "hotword": hot_meta,
438
+ }
439
+
440
+
441
+ class OnnxCacheAsrEngine(OnnxAsrEngine):
442
+ def __init__(
443
+ self,
444
+ bundle_dir: str | Path,
445
+ *,
446
+ provider: str = "CPUExecutionProvider",
447
+ intra_op_num_threads: int | None = None,
448
+ cache_precision: str = "int8",
449
+ audio_precision: str | None = None,
450
+ ) -> None:
451
+ selected_audio_precision = audio_precision or "fp32"
452
+ super().__init__(
453
+ bundle_dir,
454
+ provider=provider,
455
+ intra_op_num_threads=intra_op_num_threads,
456
+ load_lm_session=False,
457
+ audio_precision=selected_audio_precision,
458
+ )
459
+ prefill_graph = self.metadata.get("graphs", {}).get("lm_cache_prefill")
460
+ graph = self.metadata.get("graphs", {}).get("lm_cache_decode")
461
+ if not graph:
462
+ raise FileNotFoundError("Bundle metadata has no graphs.lm_cache_decode entry")
463
+ if not prefill_graph:
464
+ raise FileNotFoundError("Bundle metadata has no graphs.lm_cache_prefill entry")
465
+ cache_precision = str(cache_precision or "fp32").lower().strip()
466
+ if cache_precision not in {"fp32", "int8", "int4", "auto"}:
467
+ raise ValueError(f"Unsupported cache_precision={cache_precision!r}; use fp32, int8, int4, or auto")
468
+ graph_path = self.bundle_dir / graph["path"]
469
+ prefill_graph_path = self.bundle_dir / prefill_graph["path"]
470
+ int8_path = graph_path.with_name(f"{graph_path.stem}_int8{graph_path.suffix}")
471
+ prefill_int8_path = prefill_graph_path.with_name(f"{prefill_graph_path.stem}_int8{prefill_graph_path.suffix}")
472
+ int4_path = graph_path.with_name(f"{graph_path.stem}_int4{graph_path.suffix}")
473
+ prefill_int4_path = prefill_graph_path.with_name(f"{prefill_graph_path.stem}_int4{prefill_graph_path.suffix}")
474
+ if cache_precision in {"int8", "int4"}:
475
+ requested_paths = (prefill_int8_path, int8_path) if cache_precision == "int8" else (prefill_int4_path, int4_path)
476
+ missing = [str(path) for path in requested_paths if not path.exists()]
477
+ if missing:
478
+ raise FileNotFoundError(f"Requested {cache_precision} cache graph(s) do not exist: {missing}")
479
+ prefill_graph_path, graph_path = requested_paths
480
+ elif cache_precision == "auto":
481
+ if int8_path.exists() and prefill_int8_path.exists():
482
+ graph_path = int8_path
483
+ prefill_graph_path = prefill_int8_path
484
+ elif int4_path.exists() and prefill_int4_path.exists():
485
+ graph_path = int4_path
486
+ prefill_graph_path = prefill_int4_path
487
+
488
+ options = create_ort_session_options(intra_op_num_threads)
489
+ providers = [provider] if provider in ort.get_available_providers() else ["CPUExecutionProvider"]
490
+ if "CPUExecutionProvider" not in providers:
491
+ providers.append("CPUExecutionProvider")
492
+ self.prefill_lm_session = ort.InferenceSession(
493
+ str(prefill_graph_path),
494
+ sess_options=options,
495
+ providers=providers,
496
+ )
497
+ self.cache_lm_session = ort.InferenceSession(
498
+ str(graph_path),
499
+ sess_options=options,
500
+ providers=providers,
501
+ )
502
+ self.prefill_graph = prefill_graph
503
+ self.prefill_graph_path = prefill_graph_path
504
+ self.cache_graph = graph
505
+ self.cache_graph_path = graph_path
506
+ if graph_path == int8_path and prefill_graph_path == prefill_int8_path:
507
+ self.cache_precision = "int8"
508
+ elif graph_path == int4_path and prefill_graph_path == prefill_int4_path:
509
+ self.cache_precision = "int4"
510
+ else:
511
+ self.cache_precision = "fp32"
512
+ self.providers["lm"] = self.cache_lm_session.get_providers()
513
+ first_input = self.prefill_lm_session.get_inputs()[0]
514
+ self.lm_embed_dtype = self._ort_type_to_numpy(first_input.type)
515
+ cache_key_input = next(inp for inp in self.cache_lm_session.get_inputs() if inp.name == "cache_key_0")
516
+ self.lm_cache_dtype = self._ort_type_to_numpy(cache_key_input.type)
517
+
518
+ @staticmethod
519
+ def _ort_type_to_numpy(ort_type: str) -> np.dtype:
520
+ mapping = {
521
+ "tensor(float)": np.float32,
522
+ "tensor(float16)": np.float16,
523
+ "tensor(double)": np.float64,
524
+ "tensor(int64)": np.int64,
525
+ "tensor(int32)": np.int32,
526
+ }
527
+ if ort_type not in mapping:
528
+ raise ValueError(f"Unsupported ONNX Runtime tensor type: {ort_type}")
529
+ return mapping[ort_type]
530
+
531
+ def _new_cache(self) -> list[np.ndarray]:
532
+ graph = self.cache_graph
533
+ num_layers = int(graph["num_layers"])
534
+ max_total_len = int(graph["max_total_len"])
535
+ num_kv_heads = int(graph["num_key_value_heads"])
536
+ head_dim = int(graph["head_dim"])
537
+ caches: list[np.ndarray] = []
538
+ for _ in range(num_layers):
539
+ caches.extend(
540
+ [
541
+ np.zeros((1, num_kv_heads, max_total_len, head_dim), dtype=self.lm_cache_dtype),
542
+ np.zeros((1, num_kv_heads, max_total_len, head_dim), dtype=self.lm_cache_dtype),
543
+ ]
544
+ )
545
+ return caches
546
+
547
+ def _run_cache_prefill(self, embeds: np.ndarray, caches: list[np.ndarray]) -> np.ndarray:
548
+ graph = self.cache_graph
549
+ num_layers = int(graph["num_layers"])
550
+ max_total_len = int(graph["max_total_len"])
551
+ prompt_len = int(embeds.shape[0])
552
+ if prompt_len > max_total_len:
553
+ raise ValueError(f"prompt_len exceeds ONNX cache max_total_len: {prompt_len} > {max_total_len}")
554
+ feeds: dict[str, np.ndarray] = {
555
+ "inputs_embeds": embeds[None, :, :].astype(self.lm_embed_dtype, copy=False),
556
+ "cache_position": np.arange(prompt_len, dtype=np.int64),
557
+ }
558
+ outputs = self.prefill_lm_session.run(None, feeds)
559
+ logits = outputs[0][0, -1, :].astype(np.float32, copy=False)
560
+ for i in range(num_layers):
561
+ output_base = 1 + 2 * i
562
+ cache_base = 2 * i
563
+ caches[cache_base][:, :, :prompt_len, :] = outputs[output_base]
564
+ caches[cache_base + 1][:, :, :prompt_len, :] = outputs[output_base + 1]
565
+ return logits
566
+
567
+ def _run_cache_token(
568
+ self,
569
+ token_embed: np.ndarray,
570
+ caches: list[np.ndarray],
571
+ *,
572
+ position: int,
573
+ valid_len: int,
574
+ ) -> np.ndarray:
575
+ graph = self.cache_graph
576
+ num_layers = int(graph["num_layers"])
577
+ max_total_len = int(graph["max_total_len"])
578
+ if valid_len > max_total_len:
579
+ raise ValueError(f"valid_len exceeds ONNX cache max_total_len: {valid_len} > {max_total_len}")
580
+ attention_mask = np.zeros((1, max_total_len), dtype=np.int64)
581
+ attention_mask[:, :valid_len] = 1
582
+ feeds: dict[str, np.ndarray] = {
583
+ "inputs_embeds": token_embed.reshape(1, 1, -1).astype(self.lm_embed_dtype, copy=False),
584
+ "attention_mask": attention_mask,
585
+ "cache_position": np.asarray([position], dtype=np.int64),
586
+ }
587
+ for i in range(num_layers):
588
+ base = 2 * i
589
+ feeds[f"cache_key_{i}"] = caches[base]
590
+ feeds[f"cache_value_{i}"] = caches[base + 1]
591
+
592
+ outputs = self.cache_lm_session.run(None, feeds)
593
+ logits = outputs[0][0, -1, :].astype(np.float32, copy=False)
594
+ for i in range(num_layers):
595
+ output_base = 1 + 2 * i
596
+ cache_base = 2 * i
597
+ caches[cache_base][:, :, position : position + 1, :] = outputs[output_base]
598
+ caches[cache_base + 1][:, :, position : position + 1, :] = outputs[output_base + 1]
599
+ return logits
600
+
601
+ def transcribe(
602
+ self,
603
+ audio_bytes: bytes,
604
+ *,
605
+ language: str | None = None,
606
+ max_new_tokens: int = 128,
607
+ temperature: float = 0.5,
608
+ repetition_penalty: float = 1.0,
609
+ do_sample: bool = False,
610
+ hotwords: str | list | None = None,
611
+ hotword_topk: int = 50,
612
+ hotword_start_boost: float = 6.0,
613
+ hotword_continuation_boost: float = 8.0,
614
+ ) -> dict[str, Any]:
615
+ started = time.perf_counter()
616
+ audio = load_audio_bytes(audio_bytes, self.sampling_rate)
617
+ feature, sample_count, encoder_feature_len, hop_length = self._extract_features(audio)
618
+ audio_embeddings = self._audio_embeddings(feature, sample_count, encoder_feature_len, hop_length)
619
+ token_ids, embeds = self._initial_embeddings(audio_embeddings, language=language)
620
+ hotword_list = parse_hotwords(hotwords)
621
+ hot_trie, hot_meta = (
622
+ self._build_hotword_trie(hotword_list, hotword_start_boost, hotword_continuation_boost)
623
+ if hotword_list
624
+ else (None, None)
625
+ )
626
+
627
+ max_total_len = int(self.cache_graph["max_total_len"])
628
+ if embeds.shape[0] + int(max_new_tokens) > max_total_len:
629
+ raise ValueError(
630
+ f"Prompt + max_new_tokens exceeds cache max_total_len: "
631
+ f"{embeds.shape[0]} + {max_new_tokens} > {max_total_len}"
632
+ )
633
+
634
+ caches = self._new_cache()
635
+ if embeds.shape[0] <= 0:
636
+ raise RuntimeError("Empty prompt")
637
+ logits = self._run_cache_prefill(embeds, caches)
638
+
639
+ generated: list[int] = []
640
+ hit_stop = False
641
+ stop_token_id: int | None = None
642
+ rng = np.random.default_rng()
643
+ current_position = embeds.shape[0]
644
+ for _ in range(int(max_new_tokens)):
645
+ step_logits = logits.copy()
646
+ apply_repetition_penalty(step_logits, token_ids + generated, float(repetition_penalty))
647
+ self._mask_logits(step_logits)
648
+ if hot_trie:
649
+ self._apply_hotword_boost(step_logits, generated, hot_trie, hotword_topk)
650
+ if do_sample:
651
+ probs = softmax(step_logits / max(float(temperature), 1e-6))
652
+ next_token = int(rng.choice(np.arange(probs.shape[0]), p=probs))
653
+ else:
654
+ next_token = int(np.argmax(step_logits))
655
+ if next_token in self.eos_token_ids or next_token == self.pad_token_id:
656
+ hit_stop = True
657
+ stop_token_id = next_token
658
+ break
659
+ generated.append(next_token)
660
+ token_embed = self.token_embedding[np.asarray([next_token], dtype=np.int64)][0].astype(np.float32)
661
+ logits = self._run_cache_token(
662
+ token_embed,
663
+ caches,
664
+ position=current_position,
665
+ valid_len=current_position + 1,
666
+ )
667
+ current_position += 1
668
+
669
+ raw = self.tokenizer.decode(generated, skip_special_tokens=False)
670
+ text = normalize_prediction_text(raw)
671
+ elapsed = time.perf_counter() - started
672
+ return {
673
+ "text": text,
674
+ "raw": raw,
675
+ "generated_tokens": len(generated),
676
+ "hit_stop": hit_stop,
677
+ "stop_token_id": stop_token_id,
678
+ "elapsed_seconds": elapsed,
679
+ "audio_seconds": float(audio.shape[0]) / float(self.sampling_rate),
680
+ "audio_token_count": int(audio_embeddings.shape[0]),
681
+ "providers": self.providers,
682
+ "backend": "onnx_cache",
683
+ "cache_precision": self.cache_precision,
684
+ "audio_precision": self.audio_precision,
685
+ "hotword": hot_meta,
686
+ }
687
+
688
+
689
+ def collect_metrics() -> dict[str, Any]:
690
+ process = psutil.Process()
691
+ vm = psutil.virtual_memory()
692
+ metrics: dict[str, Any] = {
693
+ "process": {
694
+ "pid": process.pid,
695
+ "rss_bytes": int(process.memory_info().rss),
696
+ "cpu_percent": process.cpu_percent(interval=None),
697
+ },
698
+ "system": {
699
+ "total_bytes": int(vm.total),
700
+ "available_bytes": int(vm.available),
701
+ "used_bytes": int(vm.used),
702
+ "percent": float(vm.percent),
703
+ },
704
+ "gpu": {
705
+ "nvidia": None,
706
+ "apple": None,
707
+ },
708
+ }
709
+ try:
710
+ output = subprocess.check_output(
711
+ [
712
+ "nvidia-smi",
713
+ "--query-gpu=index,name,memory.used,memory.total,utilization.gpu",
714
+ "--format=csv,noheader,nounits",
715
+ ],
716
+ text=True,
717
+ timeout=1.5,
718
+ )
719
+ rows = []
720
+ for line in output.splitlines():
721
+ parts = [part.strip() for part in line.split(",")]
722
+ if len(parts) >= 5:
723
+ rows.append(
724
+ {
725
+ "index": int(parts[0]),
726
+ "name": parts[1],
727
+ "memory_used_mb": float(parts[2]),
728
+ "memory_total_mb": float(parts[3]),
729
+ "utilization_percent": float(parts[4]),
730
+ }
731
+ )
732
+ metrics["gpu"]["nvidia"] = rows
733
+ except Exception:
734
+ metrics["gpu"]["nvidia"] = []
735
+ return metrics
hotword/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Hotword helpers for Audio8 ASR ONNX runtime."""
hotword/hotword_trie.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Backend-agnostic hotword trie logit-boosting core."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any, Callable, Dict, List, Sequence, Set
7
+
8
+
9
+ _CONTROL_TOKEN_RE = re.compile(r"<\|[^>]+?\|>")
10
+ _BARE_TAG_RE = re.compile(r"</?[^>\s]+>")
11
+ _CJK_KANA_HANGUL_RE = re.compile("[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]")
12
+
13
+
14
+ class HotwordTrie:
15
+ """Prefix trie over hotword token sequences with per-step boost lookup."""
16
+
17
+ def __init__(
18
+ self,
19
+ token_sequences: Sequence[Sequence[int]],
20
+ *,
21
+ start_boost: float,
22
+ continuation_boost: float,
23
+ ) -> None:
24
+ self.start_boost = float(start_boost)
25
+ self.continuation_boost = float(continuation_boost)
26
+ self.trie: Dict[int, Dict[int, Any]] = {}
27
+ self.max_sequence_len = 0
28
+ for seq in token_sequences:
29
+ ids = [int(token_id) for token_id in seq]
30
+ if not ids:
31
+ continue
32
+ node = self.trie
33
+ for token_id in ids:
34
+ node = node.setdefault(token_id, {})
35
+ self.max_sequence_len = max(self.max_sequence_len, len(ids))
36
+ self.start_token_ids = sorted(self.trie.keys())
37
+
38
+ def __bool__(self) -> bool:
39
+ return bool(self.trie)
40
+
41
+ def boosts_for_generated(self, generated_ids: Sequence[int]) -> Dict[int, float]:
42
+ boosts: Dict[int, float] = {}
43
+ if self.start_boost:
44
+ for token_id in self.start_token_ids:
45
+ boosts[token_id] = max(boosts.get(token_id, 0.0), self.start_boost)
46
+
47
+ if not generated_ids or not self.continuation_boost or self.max_sequence_len <= 1:
48
+ return boosts
49
+
50
+ max_prefix_len = min(len(generated_ids), self.max_sequence_len - 1)
51
+ for prefix_len in range(1, max_prefix_len + 1):
52
+ node: Dict[int, Any] = self.trie
53
+ matched = True
54
+ for token_id in generated_ids[-prefix_len:]:
55
+ next_node = node.get(int(token_id))
56
+ if next_node is None:
57
+ matched = False
58
+ break
59
+ node = next_node
60
+ if not matched:
61
+ continue
62
+ for next_token_id in node.keys():
63
+ boosts[int(next_token_id)] = max(
64
+ boosts.get(int(next_token_id), 0.0), self.continuation_boost
65
+ )
66
+ return boosts
67
+
68
+
69
+ def _has_cjk_or_kana_or_hangul(text: str) -> bool:
70
+ return bool(_CJK_KANA_HANGUL_RE.search(str(text or "")))
71
+
72
+
73
+ def _hotword_text_variants(word: str) -> List[str]:
74
+ word = str(word or "").strip()
75
+ if not word:
76
+ return []
77
+ variants = [word]
78
+ if not _has_cjk_or_kana_or_hangul(word) and re.search(r"[A-Za-z0-9_]", word):
79
+ variants.append(" " + word)
80
+ out: List[str] = []
81
+ seen: Set[str] = set()
82
+ for value in variants:
83
+ if value not in seen:
84
+ seen.add(value)
85
+ out.append(value)
86
+ return out
87
+
88
+
89
+ def token_is_control_or_special(token: str, token_id: int, special_ids: Set[int]) -> bool:
90
+ if int(token_id) in special_ids:
91
+ return True
92
+ token = str(token)
93
+ return bool(_CONTROL_TOKEN_RE.fullmatch(token) or _BARE_TAG_RE.fullmatch(token))
94
+
95
+
96
+ def build_hotword_sequences(
97
+ hotwords: Sequence[str],
98
+ *,
99
+ encode: Callable[[str], List[int]],
100
+ id_to_token: Callable[[int], str],
101
+ special_ids: Set[int],
102
+ ) -> Dict[str, List[List[int]]]:
103
+ special_ids = set(int(x) for x in special_ids if x is not None)
104
+ sequences: Dict[str, List[List[int]]] = {}
105
+ seen_global: Set[tuple[int, ...]] = set()
106
+ for word in hotwords:
107
+ word = str(word or "").strip()
108
+ if not word:
109
+ continue
110
+ variants: List[List[int]] = []
111
+ for text in _hotword_text_variants(word):
112
+ ids = [
113
+ int(token_id)
114
+ for token_id in encode(text)
115
+ if not token_is_control_or_special(id_to_token(int(token_id)), int(token_id), special_ids)
116
+ ]
117
+ key = tuple(ids)
118
+ if not key or key in seen_global:
119
+ continue
120
+ seen_global.add(key)
121
+ variants.append(ids)
122
+ if variants:
123
+ sequences[word] = variants
124
+ return sequences
125
+
126
+
127
+ def flatten_sequences(sequences_by_word: Dict[str, List[List[int]]]) -> List[List[int]]:
128
+ return [ids for variants in sequences_by_word.values() for ids in variants]
129
+
130
+
131
+ def parse_hotwords(raw: Any) -> List[str]:
132
+ values: List[str] = []
133
+ if isinstance(raw, (list, tuple)):
134
+ values = [str(x).strip() for x in raw]
135
+ elif raw:
136
+ values = [x.strip() for x in re.split(r"[,,]", str(raw))]
137
+ out: List[str] = []
138
+ seen: Set[str] = set()
139
+ for value in values:
140
+ if value and value not in seen:
141
+ seen.add(value)
142
+ out.append(value)
143
+ return out
144
+
145
+
146
+ def build_trie_from_hotwords(
147
+ hotwords: Sequence[str],
148
+ *,
149
+ encode: Callable[[str], List[int]],
150
+ id_to_token: Callable[[int], str],
151
+ special_ids: Set[int],
152
+ start_boost: float,
153
+ continuation_boost: float,
154
+ ) -> tuple[HotwordTrie, Dict[str, List[List[int]]]]:
155
+ sequences_by_word = build_hotword_sequences(
156
+ hotwords, encode=encode, id_to_token=id_to_token, special_ids=special_ids
157
+ )
158
+ trie = HotwordTrie(
159
+ flatten_sequences(sequences_by_word),
160
+ start_boost=start_boost,
161
+ continuation_boost=continuation_boost,
162
+ )
163
+ return trie, sequences_by_word
measure_precision_memory.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ import threading
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import psutil
14
+
15
+ APP_DIR = Path(__file__).resolve().parent
16
+ if str(APP_DIR) not in sys.path:
17
+ sys.path.insert(0, str(APP_DIR))
18
+
19
+ from asr_onnx_runtime import OnnxCacheAsrEngine # noqa: E402
20
+
21
+
22
+ def nvidia_total_used_mb() -> tuple[float | None, list[float]]:
23
+ try:
24
+ out = subprocess.check_output(
25
+ ["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"],
26
+ text=True,
27
+ timeout=2,
28
+ )
29
+ values = [float(line.strip()) for line in out.splitlines() if line.strip()]
30
+ return sum(values), values
31
+ except Exception:
32
+ return None, []
33
+
34
+
35
+ class RequestSampler:
36
+ def __init__(self, process: psutil.Process, interval: float = 0.01) -> None:
37
+ self.process = process
38
+ self.interval = float(interval)
39
+ self.peak_rss_bytes = process.memory_info().rss
40
+ self._stop = threading.Event()
41
+ self._thread = threading.Thread(target=self._run, daemon=True)
42
+
43
+ def _run(self) -> None:
44
+ while not self._stop.wait(self.interval):
45
+ try:
46
+ self.peak_rss_bytes = max(self.peak_rss_bytes, self.process.memory_info().rss)
47
+ except psutil.Error:
48
+ break
49
+
50
+ def __enter__(self) -> "RequestSampler":
51
+ self.peak_rss_bytes = self.process.memory_info().rss
52
+ self._thread.start()
53
+ return self
54
+
55
+ def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
56
+ self._stop.set()
57
+ self._thread.join(timeout=1.0)
58
+ try:
59
+ self.peak_rss_bytes = max(self.peak_rss_bytes, self.process.memory_info().rss)
60
+ except psutil.Error:
61
+ pass
62
+
63
+
64
+ def measure_one(
65
+ *,
66
+ bundle_dir: Path,
67
+ audio_path: Path,
68
+ cache_precision: str,
69
+ audio_precision: str,
70
+ max_new_tokens: int,
71
+ provider: str,
72
+ threads: int | None,
73
+ ) -> dict[str, Any]:
74
+ process = psutil.Process()
75
+ gpu_before_total, gpu_before = nvidia_total_used_mb()
76
+ started = time.perf_counter()
77
+ engine = OnnxCacheAsrEngine(
78
+ bundle_dir,
79
+ provider=provider,
80
+ intra_op_num_threads=threads,
81
+ cache_precision=cache_precision,
82
+ audio_precision=audio_precision,
83
+ )
84
+ static_rss = process.memory_info().rss
85
+ static_gpu_total, static_gpu = nvidia_total_used_mb()
86
+ load_seconds = time.perf_counter() - started
87
+
88
+ with RequestSampler(process) as sampler:
89
+ result = engine.transcribe(audio_path.read_bytes(), language="Chinese", max_new_tokens=max_new_tokens)
90
+ post_rss = process.memory_info().rss
91
+ post_gpu_total, post_gpu = nvidia_total_used_mb()
92
+
93
+ return {
94
+ "cache_precision": cache_precision,
95
+ "audio_precision": audio_precision,
96
+ "selected_cache_precision": engine.cache_precision,
97
+ "selected_audio_precision": engine.audio_precision,
98
+ "static_rss_bytes": int(static_rss),
99
+ "infer_peak_rss_bytes": int(sampler.peak_rss_bytes),
100
+ "post_rss_bytes": int(post_rss),
101
+ "load_seconds": float(load_seconds),
102
+ "elapsed_seconds": float(result.get("elapsed_seconds") or 0.0),
103
+ "text": result.get("text"),
104
+ "generated_tokens": result.get("generated_tokens"),
105
+ "hit_stop": result.get("hit_stop"),
106
+ "gpu_total_used_mb_before": gpu_before_total,
107
+ "gpu_total_used_mb_static": static_gpu_total,
108
+ "gpu_total_used_mb_post": post_gpu_total,
109
+ "gpu_used_mb_before": gpu_before,
110
+ "gpu_used_mb_static": static_gpu,
111
+ "gpu_used_mb_post": post_gpu,
112
+ }
113
+
114
+
115
+ def main() -> None:
116
+ parser = argparse.ArgumentParser(description="Measure Audio8 ASR ONNX precision memory in a fresh process.")
117
+ parser.add_argument("--bundle_dir", type=Path, default=APP_DIR / "model_bundle")
118
+ parser.add_argument(
119
+ "--audio",
120
+ type=Path,
121
+ required=True,
122
+ help="Audio file path for the measurement.",
123
+ )
124
+ parser.add_argument("--cache_precision", choices=["fp32", "int8", "int4"], required=True)
125
+ parser.add_argument("--audio_precision", choices=["fp32", "int8"], required=True)
126
+ parser.add_argument("--max_new_tokens", type=int, default=64)
127
+ parser.add_argument("--provider", default="CPUExecutionProvider")
128
+ parser.add_argument("--threads", type=int, default=0)
129
+ args = parser.parse_args()
130
+ print(
131
+ json.dumps(
132
+ measure_one(
133
+ bundle_dir=args.bundle_dir,
134
+ audio_path=args.audio,
135
+ cache_precision=args.cache_precision,
136
+ audio_precision=args.audio_precision,
137
+ max_new_tokens=args.max_new_tokens,
138
+ provider=args.provider,
139
+ threads=args.threads if args.threads > 0 else None,
140
+ ),
141
+ ensure_ascii=False,
142
+ )
143
+ )
144
+
145
+
146
+ if __name__ == "__main__":
147
+ main()
model_bundle/added_tokens.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "<|assistant|>": 151650,
3
+ "<|audio|>": 151646,
4
+ "<|begin_of_audio|>": 151648,
5
+ "<|end_of_audio|>": 151649,
6
+ "<|endoftext|>": 151643,
7
+ "<|im_end|>": 151645,
8
+ "<|im_start|>": 151644,
9
+ "<|system|>": 151651,
10
+ "<|user|>": 151647
11
+ }
model_bundle/audio8_audio_wrapper_config.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "audio_backend": "qwen3_asr_mlp_tower",
3
+ "audio_token_id": 151646,
4
+ "audio_encoder_output_dim": 1024,
5
+ "audio_projector_file": "weights/audio_projector.npz",
6
+ "audio_mlp_tower_file": "audio_hidden.onnx",
7
+ "language_model_type": "arkasr",
8
+ "language_model_hidden_size": 512,
9
+ "native_audio_encoder_pruned": true,
10
+ "removed_native_audio_encoder_params": 91825664
11
+ }
model_bundle/audio_hidden.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5f21a8db5a8aee6c57150f3aa7464585b1a1c0ad7ea7a68e54c693df8e9511f5
3
+ size 880238173
model_bundle/audio_hidden_int8.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1d30ee323fbe69514ca197f02881ceb174229523258f225acab94ebee53682f3
3
+ size 234699007
model_bundle/chat_template.jinja ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {% for message in messages %}{% if loop.first and messages[0]['role'] != 'system' %}{{ '<|im_start|>system
2
+ You are a helpful assistant<|im_end|>
3
+ ' }}{% endif %}{{'<|im_start|>' + message['role'] + '
4
+ ' + message['content'] + '<|im_end|>' + '
5
+ '}}{% endfor %}{% if add_generation_prompt %}{{ '<|im_start|>assistant
6
+ ' }}{% endif %}
model_bundle/config.json ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "adapter_type": "mlp",
3
+ "architectures": [
4
+ "ArkasrForConditionalGeneration"
5
+ ],
6
+ "attention_dropout": 0.0,
7
+ "audio_token_id": 151646,
8
+ "bos_token_id": 151643,
9
+ "dtype": "bfloat16",
10
+ "eos_token_id": 151645,
11
+ "hidden_act": "silu",
12
+ "hidden_size": 512,
13
+ "initializer_range": 0.02,
14
+ "intermediate_size": 1408,
15
+ "layer_types": [
16
+ "full_attention",
17
+ "full_attention",
18
+ "full_attention",
19
+ "full_attention",
20
+ "full_attention",
21
+ "full_attention",
22
+ "full_attention",
23
+ "full_attention"
24
+ ],
25
+ "max_position_embeddings": 32768,
26
+ "max_whisper_length": 1500,
27
+ "max_window_layers": 28,
28
+ "merge_factor": 4,
29
+ "mlp_adapter_act": "gelu",
30
+ "model_type": "arkasr",
31
+ "num_attention_heads": 8,
32
+ "num_hidden_layers": 8,
33
+ "num_key_value_heads": 8,
34
+ "pad_token_id": 151643,
35
+ "rms_norm_eps": 1e-06,
36
+ "rope_scaling": null,
37
+ "rope_theta": 1000000.0,
38
+ "sliding_window": null,
39
+ "spec_aug": false,
40
+ "tie_word_embeddings": true,
41
+ "transformers_version": "4.57.3",
42
+ "use_cache": true,
43
+ "use_rope": false,
44
+ "use_sliding_window": false,
45
+ "vocab_size": 151936,
46
+ "whisper_config": {
47
+ "_name_or_path": "openai/whisper-small",
48
+ "activation_dropout": 0.0,
49
+ "activation_function": "gelu",
50
+ "apply_spec_augment": false,
51
+ "architectures": [
52
+ "WhisperForConditionalGeneration"
53
+ ],
54
+ "attention_dropout": 0.0,
55
+ "begin_suppress_tokens": [
56
+ 220,
57
+ 50257
58
+ ],
59
+ "bos_token_id": 50257,
60
+ "classifier_proj_size": 256,
61
+ "d_model": 768,
62
+ "decoder_attention_heads": 12,
63
+ "decoder_ffn_dim": 3072,
64
+ "decoder_layerdrop": 0.0,
65
+ "decoder_layers": 12,
66
+ "decoder_start_token_id": 50258,
67
+ "dropout": 0.0,
68
+ "dtype": "float32",
69
+ "encoder_attention_heads": 12,
70
+ "encoder_ffn_dim": 3072,
71
+ "encoder_layerdrop": 0.0,
72
+ "encoder_layers": 12,
73
+ "eos_token_id": 50257,
74
+ "forced_decoder_ids": [
75
+ [
76
+ 1,
77
+ 50259
78
+ ],
79
+ [
80
+ 2,
81
+ 50359
82
+ ],
83
+ [
84
+ 3,
85
+ 50363
86
+ ]
87
+ ],
88
+ "init_std": 0.02,
89
+ "mask_feature_length": 10,
90
+ "mask_feature_min_masks": 0,
91
+ "mask_feature_prob": 0.0,
92
+ "mask_time_length": 10,
93
+ "mask_time_min_masks": 2,
94
+ "mask_time_prob": 0.05,
95
+ "max_length": 448,
96
+ "max_source_positions": 1500,
97
+ "max_target_positions": 448,
98
+ "median_filter_width": 7,
99
+ "model_type": "whisper",
100
+ "num_hidden_layers": 12,
101
+ "num_mel_bins": 80,
102
+ "pad_token_id": 50257,
103
+ "scale_embedding": false,
104
+ "suppress_tokens": [
105
+ 1,
106
+ 2,
107
+ 7,
108
+ 8,
109
+ 9,
110
+ 10,
111
+ 14,
112
+ 25,
113
+ 26,
114
+ 27,
115
+ 28,
116
+ 29,
117
+ 31,
118
+ 58,
119
+ 59,
120
+ 60,
121
+ 61,
122
+ 62,
123
+ 63,
124
+ 90,
125
+ 91,
126
+ 92,
127
+ 93,
128
+ 359,
129
+ 503,
130
+ 522,
131
+ 542,
132
+ 873,
133
+ 893,
134
+ 902,
135
+ 918,
136
+ 922,
137
+ 931,
138
+ 1350,
139
+ 1853,
140
+ 1982,
141
+ 2460,
142
+ 2627,
143
+ 3246,
144
+ 3253,
145
+ 3268,
146
+ 3536,
147
+ 3846,
148
+ 3961,
149
+ 4183,
150
+ 4667,
151
+ 6585,
152
+ 6647,
153
+ 7273,
154
+ 9061,
155
+ 9383,
156
+ 10428,
157
+ 10929,
158
+ 11938,
159
+ 12033,
160
+ 12331,
161
+ 12562,
162
+ 13793,
163
+ 14157,
164
+ 14635,
165
+ 15265,
166
+ 15618,
167
+ 16553,
168
+ 16604,
169
+ 18362,
170
+ 18956,
171
+ 20075,
172
+ 21675,
173
+ 22520,
174
+ 26130,
175
+ 26161,
176
+ 26435,
177
+ 28279,
178
+ 29464,
179
+ 31650,
180
+ 32302,
181
+ 32470,
182
+ 36865,
183
+ 42863,
184
+ 47425,
185
+ 49870,
186
+ 50254,
187
+ 50258,
188
+ 50360,
189
+ 50361,
190
+ 50362
191
+ ],
192
+ "use_cache": true,
193
+ "use_weighted_layer_sum": false,
194
+ "vocab_size": 51865
195
+ }
196
+ }
model_bundle/generation_config.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "_from_model_config": true,
3
+ "bos_token_id": 151643,
4
+ "eos_token_id": 151645,
5
+ "pad_token_id": 151643,
6
+ "transformers_version": "4.57.3"
7
+ }
model_bundle/lm_cache_decode.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1926e5016196ac80e27c6ec51ea6affb3b5e21852c02b5425058d33a91cf903c
3
+ size 414145009
model_bundle/lm_cache_decode_int4.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0eb8ccd60f042a50c0d12d1a7fdf476fd448d5247dbb50091c3866a8ab26760c
3
+ size 133747
model_bundle/lm_cache_decode_int4.onnx.data ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fcd14cf0b65449621c6ddf052c938aa2acd72fb272bb8e0157decbf4d28eb4fd
3
+ size 55497472
model_bundle/lm_cache_decode_int8.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d7dd4e20e38ab6e453001225854b33b522569db402f54a33366f212fb271143c
3
+ size 1255083
model_bundle/lm_cache_decode_int8.onnx.data ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a2953e6835e8b7c3a36beb061122dbcf2329e6a9e2e612208645610cd095a9ee
3
+ size 103598080
model_bundle/lm_cache_prefill.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e8095de55b95e6b2303ae70a8f28b8c0092dbee2500aba61878b815b3bfab29b
3
+ size 414144056
model_bundle/lm_cache_prefill_int4.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:23c5e89eed9781d465dd235cdef371f778aadf1340f863cd02cbe616db5d6ca5
3
+ size 165158
model_bundle/lm_cache_prefill_int4.onnx.data ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f7ffe71cc716a7b2db49fd9a39cca9b714b5d30680d93760003c0b63109bf8e8
3
+ size 55464704
model_bundle/lm_cache_prefill_int8.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:410b87e57f4834b7b64529154dab8d761300f9afe46479fe5834ef642797d402
3
+ size 1308788
model_bundle/lm_cache_prefill_int8.onnx.data ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1cf8f2333339d9e3b8902cde04928feab9c76c74aea57a440aff301bfbc2e92a
3
+ size 103565312
model_bundle/lm_logits.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4fa77550bac03603ac239e7c1634208617b8399c93eaeda3d64cde8c9d55e988
3
+ size 414132615
model_bundle/merges.txt ADDED
The diff for this file is too large to render. See raw diff
 
model_bundle/metadata.json ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "format": "audio8_asr_onnx_bundle",
3
+ "format_version": 1,
4
+ "sampling_rate": 16000,
5
+ "max_audio_seconds": 30,
6
+ "prompt_mode": "arkasr_chat",
7
+ "response_prefix": "",
8
+ "language_hint_enabled": false,
9
+ "runtime_notes": {
10
+ "audio_loader": "librosa_first_for_hf_eval_parity"
11
+ },
12
+ "prompt_audio": {
13
+ "token_count_rule": "arkasr_processor_floor_mel_downsample_merge",
14
+ "merge_factor": 4,
15
+ "hop_length": 160
16
+ },
17
+ "tokens": {
18
+ "user_token": "<|user|>",
19
+ "assistant_token": "<|assistant|>",
20
+ "bos_audio_token": "<|begin_of_audio|>",
21
+ "audio_token": "<|audio|>",
22
+ "eos_audio_token": "<|end_of_audio|>",
23
+ "assistant_end_token": "<|im_end|>",
24
+ "audio_token_id": 151646,
25
+ "pad_token_id": 151643,
26
+ "eos_token_ids": [
27
+ 151645
28
+ ],
29
+ "asr_block_token_id_from": -1,
30
+ "extra_block_token_ids": [
31
+ 151647,
32
+ 151650,
33
+ 151648,
34
+ 151646,
35
+ 151649
36
+ ],
37
+ "vocab_size": 151652
38
+ },
39
+ "audio_encoder": {
40
+ "type": "qwen3_asr_mlp_tower",
41
+ "qwen3_asr_audio_output_mode": "mlp_tower",
42
+ "prompt_token_count_uses_arkasr_processor_rule": true
43
+ },
44
+ "graphs": {
45
+ "audio_hidden": {
46
+ "path": "audio_hidden.onnx",
47
+ "max_frames": 3000,
48
+ "frames_padded": 3000,
49
+ "feature_size": 128,
50
+ "max_audio_tokens": 390,
51
+ "hidden_size": 1024,
52
+ "tokens_per_chunk": 13,
53
+ "valid_mask_sum_at_max": 390
54
+ },
55
+ "audio_hidden_int8": {
56
+ "path": "audio_hidden_int8.onnx",
57
+ "quantization": "dynamic_int8_matmul_quint8_per_channel",
58
+ "source_graph": "audio_hidden.onnx",
59
+ "max_frames": 3000,
60
+ "frames_padded": 3000,
61
+ "feature_size": 128,
62
+ "max_audio_tokens": 390,
63
+ "hidden_size": 1024,
64
+ "tokens_per_chunk": 13,
65
+ "valid_mask_sum_at_max": 390
66
+ },
67
+ "lm_logits": {
68
+ "path": "lm_logits.onnx",
69
+ "hidden_size": 512,
70
+ "dummy_sequence_length": 16,
71
+ "patched_negative_transpose_perms": 0
72
+ },
73
+ "lm_cache_prefill": {
74
+ "path": "lm_cache_prefill.onnx",
75
+ "hidden_size": 512,
76
+ "num_layers": 8,
77
+ "num_attention_heads": 8,
78
+ "num_key_value_heads": 8,
79
+ "head_dim": 64,
80
+ "max_total_len": 512,
81
+ "dummy_sequence_length": 16,
82
+ "patched_negative_transpose_perms": 0
83
+ },
84
+ "lm_cache_decode": {
85
+ "path": "lm_cache_decode.onnx",
86
+ "hidden_size": 512,
87
+ "num_layers": 8,
88
+ "num_attention_heads": 8,
89
+ "num_key_value_heads": 8,
90
+ "head_dim": 64,
91
+ "max_total_len": 512,
92
+ "patched_negative_transpose_perms": 0
93
+ }
94
+ },
95
+ "weights": {
96
+ "token_embedding": "weights/token_embedding.npy",
97
+ "audio_projector": "weights/audio_projector.npz"
98
+ },
99
+ "generation_defaults": {
100
+ "max_new_tokens": 128,
101
+ "do_sample": false,
102
+ "temperature": 0.5,
103
+ "repetition_penalty": 1.0
104
+ }
105
+ }
model_bundle/preprocessor_config.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "chunk_length": 30,
3
+ "dither": 0.0,
4
+ "feature_extractor_type": "WhisperFeatureExtractor",
5
+ "feature_size": 80,
6
+ "hop_length": 160,
7
+ "n_fft": 400,
8
+ "n_samples": 480000,
9
+ "nb_max_frames": 3000,
10
+ "padding_side": "right",
11
+ "padding_value": 0.0,
12
+ "processor_class": "ArkasrProcessor",
13
+ "return_attention_mask": false,
14
+ "sampling_rate": 16000
15
+ }
model_bundle/processor_config.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "audio_dtype": "bfloat16",
3
+ "audio_token": "<|audio|>",
4
+ "merge_factor": 4,
5
+ "processor_class": "ArkasrProcessor"
6
+ }
model_bundle/qwen3_asr_feature_extractor/preprocessor_config.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "chunk_length": 30,
3
+ "dither": 0.0,
4
+ "feature_extractor_type": "WhisperFeatureExtractor",
5
+ "feature_size": 128,
6
+ "hop_length": 160,
7
+ "n_fft": 400,
8
+ "n_samples": 480000,
9
+ "nb_max_frames": 3000,
10
+ "padding_side": "right",
11
+ "padding_value": 0.0,
12
+ "processor_class": "Qwen3ASRProcessor",
13
+ "return_attention_mask": true,
14
+ "sampling_rate": 16000
15
+ }
model_bundle/special_tokens_map.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "additional_special_tokens": [
3
+ "<|user|>",
4
+ "<|begin_of_audio|>",
5
+ "<|end_of_audio|>",
6
+ "<|assistant|>",
7
+ "<|system|>"
8
+ ],
9
+ "eos_token": {
10
+ "content": "<|im_end|>",
11
+ "lstrip": false,
12
+ "normalized": false,
13
+ "rstrip": false,
14
+ "single_word": false
15
+ },
16
+ "pad_token": {
17
+ "content": "<|endoftext|>",
18
+ "lstrip": false,
19
+ "normalized": false,
20
+ "rstrip": false,
21
+ "single_word": false
22
+ }
23
+ }
model_bundle/tokenizer.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:64ecaabc85a9272a8099f8aa7c097ce3a4c65a79bafbfd1450add736938ae45b
3
+ size 11419524
model_bundle/tokenizer_config.json ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_prefix_space": false,
3
+ "added_tokens_decoder": {
4
+ "151643": {
5
+ "content": "<|endoftext|>",
6
+ "lstrip": false,
7
+ "normalized": false,
8
+ "rstrip": false,
9
+ "single_word": false,
10
+ "special": true
11
+ },
12
+ "151644": {
13
+ "content": "<|im_start|>",
14
+ "lstrip": false,
15
+ "normalized": false,
16
+ "rstrip": false,
17
+ "single_word": false,
18
+ "special": true
19
+ },
20
+ "151645": {
21
+ "content": "<|im_end|>",
22
+ "lstrip": false,
23
+ "normalized": false,
24
+ "rstrip": false,
25
+ "single_word": false,
26
+ "special": true
27
+ },
28
+ "151646": {
29
+ "content": "<|audio|>",
30
+ "lstrip": false,
31
+ "normalized": true,
32
+ "rstrip": false,
33
+ "single_word": false,
34
+ "special": false
35
+ },
36
+ "151647": {
37
+ "content": "<|user|>",
38
+ "lstrip": false,
39
+ "normalized": false,
40
+ "rstrip": false,
41
+ "single_word": false,
42
+ "special": true
43
+ },
44
+ "151648": {
45
+ "content": "<|begin_of_audio|>",
46
+ "lstrip": false,
47
+ "normalized": false,
48
+ "rstrip": false,
49
+ "single_word": false,
50
+ "special": true
51
+ },
52
+ "151649": {
53
+ "content": "<|end_of_audio|>",
54
+ "lstrip": false,
55
+ "normalized": false,
56
+ "rstrip": false,
57
+ "single_word": false,
58
+ "special": true
59
+ },
60
+ "151650": {
61
+ "content": "<|assistant|>",
62
+ "lstrip": false,
63
+ "normalized": false,
64
+ "rstrip": false,
65
+ "single_word": false,
66
+ "special": true
67
+ },
68
+ "151651": {
69
+ "content": "<|system|>",
70
+ "lstrip": false,
71
+ "normalized": false,
72
+ "rstrip": false,
73
+ "single_word": false,
74
+ "special": true
75
+ }
76
+ },
77
+ "additional_special_tokens": [
78
+ "<|user|>",
79
+ "<|begin_of_audio|>",
80
+ "<|end_of_audio|>",
81
+ "<|assistant|>",
82
+ "<|system|>"
83
+ ],
84
+ "bos_token": null,
85
+ "clean_up_tokenization_spaces": false,
86
+ "eos_token": "<|im_end|>",
87
+ "errors": "replace",
88
+ "extra_special_tokens": {},
89
+ "fix_mistral_regex": true,
90
+ "model_max_length": 32768,
91
+ "pad_token": "<|endoftext|>",
92
+ "processor_class": "ArkasrProcessor",
93
+ "split_special_tokens": false,
94
+ "tokenizer_class": "Qwen2Tokenizer",
95
+ "unk_token": null
96
+ }
model_bundle/vocab.json ADDED
The diff for this file is too large to render. See raw diff
 
model_bundle/weights/audio_projector.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c15eec79b817abab7de6c593845de150f984e0afec9ec41a92d1f89ab75470a5
3
+ size 2108430
model_bundle/weights/token_embedding.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b8d6949be746c1e11335250a67ef9c58f7b01e2fcc89edf265743a8484114ac5
3
+ size 311165056
requirements-onnx.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Pinned for reproducible CPU ONNX Runtime behavior.
2
+ fastapi==0.139.0
3
+ uvicorn[standard]==0.49.0
4
+ python-multipart==0.0.32
5
+ numpy==1.26.4
6
+ onnxruntime==1.22.0
7
+ psutil==7.0.0
8
+ librosa==0.11.0
9
+ soundfile==0.14.0
10
+ tokenizers==0.22.2
11
+ transformers==4.57.6
run_local.sh ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ BUNDLE_DIR="${1:-${AUDIO8_ASR_BUNDLE:-$SCRIPT_DIR/model_bundle}}"
6
+ HOST="${HOST:-127.0.0.1}"
7
+ PORT="${PORT:-7860}"
8
+ ORT_PROVIDER="${ORT_PROVIDER:-CPUExecutionProvider}"
9
+ ASR_BACKEND="${ASR_BACKEND:-auto}"
10
+ ASR_CACHE_PRECISION="${ASR_CACHE_PRECISION:-int8}"
11
+ ASR_AUDIO_PRECISION="${ASR_AUDIO_PRECISION:-int8}"
12
+ PYTHON_BIN="${PYTHON:-python3}"
13
+
14
+ cd "$SCRIPT_DIR"
15
+ export NO_PROXY="${NO_PROXY:+$NO_PROXY,}127.0.0.1,localhost,::1"
16
+ export no_proxy="${no_proxy:+$no_proxy,}127.0.0.1,localhost,::1"
17
+ if ! "$PYTHON_BIN" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)'; then
18
+ echo "Python 3.10+ is required. Create/activate a newer venv, then rerun this script." >&2
19
+ exit 1
20
+ fi
21
+ exec "$PYTHON_BIN" server.py \
22
+ --bundle_dir "$BUNDLE_DIR" \
23
+ --backend "$ASR_BACKEND" \
24
+ --host "$HOST" \
25
+ --port "$PORT" \
26
+ --provider "$ORT_PROVIDER" \
27
+ --cache_precision "$ASR_CACHE_PRECISION" \
28
+ --audio_precision "$ASR_AUDIO_PRECISION"
server.py ADDED
@@ -0,0 +1,359 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import gc
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import threading
9
+ from typing import Any
10
+
11
+ from fastapi import FastAPI, File, Form, HTTPException, UploadFile
12
+ from fastapi.responses import FileResponse
13
+ from fastapi.staticfiles import StaticFiles
14
+ import psutil
15
+
16
+ from asr_onnx_runtime import OnnxAsrEngine, OnnxCacheAsrEngine, collect_metrics
17
+
18
+
19
+ APP_DIR = Path(__file__).resolve().parent
20
+ STATIC_DIR = APP_DIR / "static"
21
+ ENGINE: OnnxAsrEngine | None = None
22
+ ENGINE_INFO: dict[str, Any] = {}
23
+ ENGINE_ARGS: argparse.Namespace | None = None
24
+ ENGINE_LOCK = threading.RLock()
25
+ PROCESS = psutil.Process()
26
+ SERVICE_PEAK_RSS = PROCESS.memory_info().rss
27
+ LAST_REQUEST_PEAK_RSS = SERVICE_PEAK_RSS
28
+
29
+
30
+ app = FastAPI(title="Audio8 ASR Local")
31
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
32
+
33
+
34
+ def release_engine() -> None:
35
+ global ENGINE
36
+ ENGINE = None
37
+ gc.collect()
38
+
39
+
40
+ class RequestMemorySampler:
41
+ def __init__(self, sample_interval_s: float = 0.05) -> None:
42
+ self.sample_interval_s = float(sample_interval_s)
43
+ self.peak_rss_bytes = PROCESS.memory_info().rss
44
+ self._stop = threading.Event()
45
+ self._thread: threading.Thread | None = None
46
+
47
+ def _run(self) -> None:
48
+ while not self._stop.wait(self.sample_interval_s):
49
+ self.peak_rss_bytes = max(self.peak_rss_bytes, PROCESS.memory_info().rss)
50
+
51
+ def start(self) -> None:
52
+ self.peak_rss_bytes = PROCESS.memory_info().rss
53
+ self._thread = threading.Thread(target=self._run, daemon=True)
54
+ self._thread.start()
55
+
56
+ def stop(self) -> int:
57
+ self.peak_rss_bytes = max(self.peak_rss_bytes, PROCESS.memory_info().rss)
58
+ self._stop.set()
59
+ if self._thread is not None:
60
+ self._thread.join(timeout=1.0)
61
+ return self.peak_rss_bytes
62
+
63
+
64
+ def update_service_peak(candidate: int | None = None) -> None:
65
+ global SERVICE_PEAK_RSS
66
+ current = PROCESS.memory_info().rss
67
+ values = [SERVICE_PEAK_RSS, current]
68
+ if candidate is not None:
69
+ values.append(int(candidate))
70
+ SERVICE_PEAK_RSS = max(values)
71
+
72
+
73
+ def available_cache_precisions(bundle_dir: str | Path) -> list[str]:
74
+ bundle_path = Path(bundle_dir).expanduser().resolve()
75
+ metadata_path = bundle_path / "metadata.json"
76
+ if not metadata_path.exists():
77
+ return []
78
+ metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
79
+ prefill_graph = metadata.get("graphs", {}).get("lm_cache_prefill")
80
+ graph = metadata.get("graphs", {}).get("lm_cache_decode")
81
+ if not graph or not prefill_graph:
82
+ return []
83
+ prefill_graph_path = bundle_path / prefill_graph["path"]
84
+ graph_path = bundle_path / graph["path"]
85
+ values = []
86
+ if prefill_graph_path.exists() and graph_path.exists():
87
+ values.append("fp32")
88
+ prefill_int8_path = prefill_graph_path.with_name(f"{prefill_graph_path.stem}_int8{prefill_graph_path.suffix}")
89
+ int8_path = graph_path.with_name(f"{graph_path.stem}_int8{graph_path.suffix}")
90
+ if prefill_int8_path.exists() and int8_path.exists():
91
+ values.append("int8")
92
+ prefill_int4_path = prefill_graph_path.with_name(f"{prefill_graph_path.stem}_int4{prefill_graph_path.suffix}")
93
+ int4_path = graph_path.with_name(f"{graph_path.stem}_int4{graph_path.suffix}")
94
+ if prefill_int4_path.exists() and int4_path.exists():
95
+ values.append("int4")
96
+ return values
97
+
98
+
99
+ def available_audio_precisions(bundle_dir: str | Path) -> list[str]:
100
+ bundle_path = Path(bundle_dir).expanduser().resolve()
101
+ metadata_path = bundle_path / "metadata.json"
102
+ if not metadata_path.exists():
103
+ return []
104
+ metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
105
+ graphs = metadata.get("graphs", {})
106
+ values = []
107
+ fp32_graph = graphs.get("audio_hidden")
108
+ if fp32_graph and (bundle_path / fp32_graph["path"]).exists():
109
+ values.append("fp32")
110
+ int8_graph = graphs.get("audio_hidden_int8")
111
+ if int8_graph and (bundle_path / int8_graph["path"]).exists():
112
+ values.append("int8")
113
+ return values
114
+
115
+
116
+ def runtime_snapshot() -> dict[str, Any]:
117
+ update_service_peak()
118
+ bundle_dir = ENGINE_INFO.get("bundle_dir")
119
+ return {
120
+ **ENGINE_INFO,
121
+ "runtime_loaded": ENGINE is not None,
122
+ "available_cache_precisions": available_cache_precisions(bundle_dir) if bundle_dir else [],
123
+ "available_audio_precisions": available_audio_precisions(bundle_dir) if bundle_dir else [],
124
+ "service_peak_rss_bytes": int(SERVICE_PEAK_RSS),
125
+ "last_request_peak_rss_bytes": int(LAST_REQUEST_PEAK_RSS),
126
+ }
127
+
128
+
129
+ def load_engine(
130
+ args: argparse.Namespace,
131
+ *,
132
+ backend: str | None = None,
133
+ cache_precision: str | None = None,
134
+ audio_precision: str | None = None,
135
+ release_existing: bool = False,
136
+ ) -> None:
137
+ global ENGINE, ENGINE_INFO
138
+ if release_existing:
139
+ release_engine()
140
+ selected_backend = backend or args.backend
141
+ selected_precision = cache_precision or args.cache_precision
142
+ selected_audio_precision = audio_precision if audio_precision is not None else getattr(args, "audio_precision", None)
143
+ threads = args.threads if args.threads > 0 else None
144
+
145
+ if selected_backend in {"auto", "onnx_cache"}:
146
+ try:
147
+ engine = OnnxCacheAsrEngine(
148
+ args.bundle_dir,
149
+ provider=args.provider,
150
+ intra_op_num_threads=threads,
151
+ cache_precision=selected_precision,
152
+ audio_precision=selected_audio_precision,
153
+ )
154
+ ENGINE = engine
155
+ ENGINE_INFO = {
156
+ "bundle_dir": str(engine.bundle_dir),
157
+ "backend": "onnx_cache",
158
+ "cache_precision": engine.cache_precision,
159
+ "cache_prefill_graph": str(engine.prefill_graph_path),
160
+ "cache_graph": str(engine.cache_graph_path),
161
+ "audio_precision": engine.audio_precision,
162
+ "audio_graph": str(engine.audio_graph_path),
163
+ "providers": engine.providers,
164
+ }
165
+ update_service_peak()
166
+ return
167
+ except Exception:
168
+ if selected_backend == "onnx_cache":
169
+ raise
170
+
171
+ engine = OnnxAsrEngine(
172
+ args.bundle_dir,
173
+ provider=args.provider,
174
+ intra_op_num_threads=threads,
175
+ audio_precision=selected_audio_precision or "fp32",
176
+ )
177
+ ENGINE = engine
178
+ ENGINE_INFO = {
179
+ "bundle_dir": str(engine.bundle_dir),
180
+ "backend": "onnx",
181
+ "cache_precision": None,
182
+ "cache_graph": None,
183
+ "audio_precision": engine.audio_precision,
184
+ "audio_graph": str(engine.audio_graph_path),
185
+ "providers": engine.providers,
186
+ }
187
+ update_service_peak()
188
+
189
+
190
+ @app.get("/")
191
+ async def index() -> FileResponse:
192
+ return FileResponse(STATIC_DIR / "index.html")
193
+
194
+
195
+ @app.get("/health")
196
+ async def health() -> dict[str, Any]:
197
+ if ENGINE is None:
198
+ return {"ok": False, "error": "engine not loaded"}
199
+ return {"ok": True, **runtime_snapshot()}
200
+
201
+
202
+ @app.get("/api/runtime")
203
+ async def runtime() -> dict[str, Any]:
204
+ return {"ok": ENGINE is not None, "runtime": runtime_snapshot()}
205
+
206
+
207
+ @app.post("/api/reload")
208
+ async def reload_runtime(
209
+ backend: str = Form("onnx_cache"),
210
+ cache_precision: str | None = Form(None),
211
+ audio_precision: str | None = Form(None),
212
+ ) -> dict[str, Any]:
213
+ if ENGINE_ARGS is None:
214
+ raise HTTPException(status_code=503, detail="Server arguments are not initialized")
215
+ if backend not in {"onnx_cache", "onnx", "auto"}:
216
+ raise HTTPException(status_code=400, detail=f"Unsupported backend: {backend}")
217
+ if cache_precision:
218
+ cache_precision = cache_precision.lower().strip()
219
+ if cache_precision not in {"fp32", "int8", "int4", "auto"}:
220
+ raise HTTPException(status_code=400, detail=f"Unsupported cache precision: {cache_precision}")
221
+ else:
222
+ cache_precision = str(ENGINE_INFO.get("cache_precision") or "").lower().strip() or ENGINE_ARGS.cache_precision
223
+ if audio_precision:
224
+ audio_precision = audio_precision.lower().strip()
225
+ if audio_precision not in {"fp32", "int8", "auto"}:
226
+ raise HTTPException(status_code=400, detail=f"Unsupported audio precision: {audio_precision}")
227
+ else:
228
+ audio_precision = str(ENGINE_INFO.get("audio_precision") or "").lower().strip() or None
229
+ with ENGINE_LOCK:
230
+ try:
231
+ load_engine(
232
+ ENGINE_ARGS,
233
+ backend=backend,
234
+ cache_precision=cache_precision,
235
+ audio_precision=audio_precision,
236
+ release_existing=True,
237
+ )
238
+ except Exception as exc:
239
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
240
+ return {"ok": True, "runtime": runtime_snapshot()}
241
+
242
+
243
+ @app.get("/metrics")
244
+ async def metrics() -> dict[str, Any]:
245
+ data = collect_metrics()
246
+ data["runtime"] = runtime_snapshot()
247
+ return data
248
+
249
+
250
+ @app.post("/asr")
251
+ async def asr(
252
+ audio: UploadFile = File(...),
253
+ language: str | None = Form(None),
254
+ max_new_tokens: int = Form(128),
255
+ cache_precision: str | None = Form(None),
256
+ audio_precision: str | None = Form(None),
257
+ hotwords: str | None = Form(None),
258
+ hotword_topk: int = Form(50),
259
+ hotword_start_boost: float = Form(6.0),
260
+ hotword_continuation_boost: float = Form(8.0),
261
+ ) -> dict[str, Any]:
262
+ global LAST_REQUEST_PEAK_RSS
263
+ if ENGINE is None:
264
+ raise HTTPException(status_code=503, detail="ASR engine is not loaded")
265
+ audio_bytes = await audio.read()
266
+ if not audio_bytes:
267
+ raise HTTPException(status_code=400, detail="Empty audio upload")
268
+ if ENGINE_ARGS is not None:
269
+ requested_cache = cache_precision.lower().strip() if cache_precision else None
270
+ requested_audio = audio_precision.lower().strip() if audio_precision else None
271
+ if requested_cache and requested_cache not in {"fp32", "int8", "int4", "auto"}:
272
+ raise HTTPException(status_code=400, detail=f"Unsupported cache precision: {requested_cache}")
273
+ if requested_audio and requested_audio not in {"fp32", "int8", "auto"}:
274
+ raise HTTPException(status_code=400, detail=f"Unsupported audio precision: {requested_audio}")
275
+ current_cache = str(ENGINE_INFO.get("cache_precision") or "").lower().strip()
276
+ current_audio = str(ENGINE_INFO.get("audio_precision") or "").lower().strip()
277
+ target_cache = requested_cache or current_cache or ENGINE_ARGS.cache_precision
278
+ target_audio = requested_audio or current_audio or getattr(ENGINE_ARGS, "audio_precision", None)
279
+ should_reload = (
280
+ ENGINE_INFO.get("backend") == "onnx_cache"
281
+ and (
282
+ (requested_cache is not None and target_cache != current_cache)
283
+ or (requested_audio is not None and target_audio != current_audio)
284
+ )
285
+ )
286
+ if should_reload:
287
+ with ENGINE_LOCK:
288
+ try:
289
+ load_engine(
290
+ ENGINE_ARGS,
291
+ backend="onnx_cache",
292
+ cache_precision=target_cache,
293
+ audio_precision=target_audio,
294
+ release_existing=True,
295
+ )
296
+ except Exception as exc:
297
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
298
+ sampler = RequestMemorySampler()
299
+ sampler.start()
300
+ try:
301
+ with ENGINE_LOCK:
302
+ if ENGINE is None:
303
+ raise HTTPException(status_code=503, detail="ASR engine is not loaded")
304
+ result = ENGINE.transcribe(
305
+ audio_bytes,
306
+ language=language or None,
307
+ max_new_tokens=max_new_tokens,
308
+ hotwords=hotwords or None,
309
+ hotword_topk=int(hotword_topk),
310
+ hotword_start_boost=float(hotword_start_boost),
311
+ hotword_continuation_boost=float(hotword_continuation_boost),
312
+ )
313
+ LAST_REQUEST_PEAK_RSS = max(int(sampler.stop()), PROCESS.memory_info().rss)
314
+ update_service_peak(LAST_REQUEST_PEAK_RSS)
315
+ result["request_peak_rss_bytes"] = int(LAST_REQUEST_PEAK_RSS)
316
+ result["runtime"] = runtime_snapshot()
317
+ return result
318
+ except Exception as exc:
319
+ LAST_REQUEST_PEAK_RSS = max(int(sampler.stop()), PROCESS.memory_info().rss)
320
+ update_service_peak(LAST_REQUEST_PEAK_RSS)
321
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
322
+
323
+
324
+ def parse_args() -> argparse.Namespace:
325
+ parser = argparse.ArgumentParser(description="Run the local Audio8 ASR ONNX web UI.")
326
+ parser.add_argument(
327
+ "--bundle_dir",
328
+ default=os.environ.get("AUDIO8_ASR_BUNDLE", "model_bundle"),
329
+ )
330
+ parser.add_argument(
331
+ "--backend",
332
+ choices=["auto", "onnx", "onnx_cache"],
333
+ default=os.environ.get("ASR_BACKEND", "auto"),
334
+ )
335
+ parser.add_argument("--host", default=os.environ.get("HOST", "127.0.0.1"))
336
+ parser.add_argument("--port", type=int, default=int(os.environ.get("PORT", "7860")))
337
+ parser.add_argument("--provider", default=os.environ.get("ORT_PROVIDER", "CPUExecutionProvider"))
338
+ parser.add_argument("--cache_precision", default=os.environ.get("ASR_CACHE_PRECISION", "auto"))
339
+ parser.add_argument("--audio_precision", choices=["fp32", "int8", "auto"], default=os.environ.get("ASR_AUDIO_PRECISION", "auto"))
340
+ parser.add_argument("--threads", type=int, default=int(os.environ.get("ORT_THREADS", "0")))
341
+ return parser.parse_args()
342
+
343
+
344
+ def main() -> None:
345
+ global ENGINE_ARGS
346
+ args = parse_args()
347
+ ENGINE_ARGS = args
348
+ load_engine(args)
349
+ _run_uvicorn(args)
350
+
351
+
352
+ def _run_uvicorn(args: argparse.Namespace) -> None:
353
+ import uvicorn
354
+
355
+ uvicorn.run(app, host=args.host, port=args.port)
356
+
357
+
358
+ if __name__ == "__main__":
359
+ main()
smoke_test.sh ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ HOST="${1:-127.0.0.1}"
5
+ PORT="${2:-7860}"
6
+ if [[ $# -lt 3 ]]; then
7
+ echo "Usage: $0 [host] [port] /path/to/audio.wav" >&2
8
+ exit 2
9
+ fi
10
+ AUDIO_PATH="$3"
11
+ BASE_URL="http://${HOST}:${PORT}"
12
+ PYTHON_BIN="${PYTHON:-python3}"
13
+ CURL_LOCAL=(curl --noproxy "*" -fsS)
14
+
15
+ echo "[smoke] health ${BASE_URL}/health"
16
+ "${CURL_LOCAL[@]}" "${BASE_URL}/health" | "$PYTHON_BIN" -m json.tool
17
+
18
+ echo "[smoke] asr ${AUDIO_PATH}"
19
+ "${CURL_LOCAL[@]}" -X POST "${BASE_URL}/asr" \
20
+ -F "audio=@${AUDIO_PATH}" \
21
+ -F "language=Chinese" \
22
+ -F "max_new_tokens=64" \
23
+ -F "cache_precision=int8" \
24
+ -F "audio_precision=int8" \
25
+ | "$PYTHON_BIN" -m json.tool
static/app.js ADDED
@@ -0,0 +1,459 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const recordBtn = document.getElementById("recordBtn");
2
+ const stopBtn = document.getElementById("stopBtn");
3
+ const uploadBtn = document.getElementById("uploadBtn");
4
+ const fileInput = document.getElementById("fileInput");
5
+ const preview = document.getElementById("preview");
6
+ const transcript = document.getElementById("transcript");
7
+ const raw = document.getElementById("raw");
8
+ const health = document.getElementById("health");
9
+ const recordStatus = document.getElementById("recordStatus");
10
+ const maxTokens = document.getElementById("maxTokens");
11
+ const hotwords = document.getElementById("hotwords");
12
+ const hotwordStrengthSegments = document.getElementById("hotwordStrengthSegments");
13
+ const latency = document.getElementById("latency");
14
+ const precisionSegments = document.getElementById("precisionSegments");
15
+ const precisionNote = document.getElementById("precisionNote");
16
+ const audioPrecisionSegments = document.getElementById("audioPrecisionSegments");
17
+ const audioPrecisionNote = document.getElementById("audioPrecisionNote");
18
+
19
+ let stream = null;
20
+ let audioContext = null;
21
+ let sourceNode = null;
22
+ let processorNode = null;
23
+ let monitorGain = null;
24
+ let recordedBuffers = [];
25
+ let recordingSampleRate = 16000;
26
+ let isStoppingRecording = false;
27
+ const stopTailCaptureMs = 80;
28
+ const wavTailSilenceMs = 300;
29
+ let selectedPrecision = "int8";
30
+ let availablePrecisions = ["fp32", "int8", "int4"];
31
+ let selectedAudioPrecision = "int8";
32
+ let availableAudioPrecisions = ["fp32", "int8"];
33
+ let selectedHotwordStrength = "normal";
34
+ const hotwordStrengths = {
35
+ normal: {
36
+ topk: 50,
37
+ startBoost: 6.0,
38
+ continuationBoost: 8.0,
39
+ },
40
+ strong: {
41
+ topk: 50,
42
+ startBoost: 7.0,
43
+ continuationBoost: 9.0,
44
+ },
45
+ };
46
+
47
+ function apiUrl(path) {
48
+ if (window.location.protocol === "file:") {
49
+ return `http://127.0.0.1:7860${path}`;
50
+ }
51
+ return path;
52
+ }
53
+
54
+ function formatBytes(bytes) {
55
+ if (!Number.isFinite(bytes)) return "--";
56
+ const units = ["B", "KB", "MB", "GB", "TB"];
57
+ let value = bytes;
58
+ let unit = 0;
59
+ while (value >= 1024 && unit < units.length - 1) {
60
+ value /= 1024;
61
+ unit += 1;
62
+ }
63
+ return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
64
+ }
65
+
66
+ function formatSeconds(value) {
67
+ const number = Number(value);
68
+ return Number.isFinite(number) ? `${number.toFixed(2)}s` : "--";
69
+ }
70
+
71
+ function setBusy(isBusy, label = "Ready") {
72
+ recordStatus.textContent = label;
73
+ recordBtn.disabled = isBusy;
74
+ uploadBtn.disabled = isBusy;
75
+ precisionSegments.querySelectorAll("button").forEach((button) => {
76
+ button.disabled = isBusy || !availablePrecisions.includes(button.dataset.precision);
77
+ });
78
+ audioPrecisionSegments.querySelectorAll("button").forEach((button) => {
79
+ button.disabled = isBusy || !availableAudioPrecisions.includes(button.dataset.audioPrecision);
80
+ });
81
+ hotwordStrengthSegments.querySelectorAll("button").forEach((button) => {
82
+ button.disabled = isBusy;
83
+ });
84
+ }
85
+
86
+ function setPrecisionButtons() {
87
+ precisionSegments.querySelectorAll("button").forEach((button) => {
88
+ const precision = button.dataset.precision;
89
+ button.classList.toggle("active", precision === selectedPrecision);
90
+ button.disabled = !availablePrecisions.includes(precision);
91
+ });
92
+ precisionNote.textContent = availablePrecisions.length ? `${availablePrecisions.join(" / ")} available` : "cache unavailable";
93
+ }
94
+
95
+ function setAudioPrecisionButtons() {
96
+ audioPrecisionSegments.querySelectorAll("button").forEach((button) => {
97
+ const precision = button.dataset.audioPrecision;
98
+ button.classList.toggle("active", precision === selectedAudioPrecision);
99
+ button.disabled = !availableAudioPrecisions.includes(precision);
100
+ });
101
+ audioPrecisionNote.textContent = availableAudioPrecisions.length
102
+ ? `${availableAudioPrecisions.join(" / ")} available`
103
+ : "audio quant unavailable";
104
+ }
105
+
106
+ function setHotwordStrengthButtons() {
107
+ hotwordStrengthSegments.querySelectorAll("button").forEach((button) => {
108
+ const strength = button.dataset.hotwordStrength;
109
+ button.classList.toggle("active", strength === selectedHotwordStrength);
110
+ });
111
+ }
112
+
113
+ function applyRuntime(runtime) {
114
+ if (!runtime) return;
115
+ availablePrecisions = Array.isArray(runtime.available_cache_precisions)
116
+ ? runtime.available_cache_precisions
117
+ : ["fp32"];
118
+ availableAudioPrecisions = Array.isArray(runtime.available_audio_precisions)
119
+ ? runtime.available_audio_precisions
120
+ : ["fp32"];
121
+ selectedPrecision = runtime.cache_precision || selectedPrecision || "fp32";
122
+ selectedAudioPrecision = runtime.audio_precision || selectedAudioPrecision || "fp32";
123
+ setPrecisionButtons();
124
+ setAudioPrecisionButtons();
125
+ health.textContent = `${runtime.backend || "--"} · ${runtime.cache_precision || "n/a"} · audio ${runtime.audio_precision || "n/a"}`;
126
+ document.getElementById("backend").textContent = runtime.backend || "--";
127
+ document.getElementById("precision").textContent = `${runtime.cache_precision || "n/a"} / ${runtime.audio_precision || "n/a"}`;
128
+ document.getElementById("peakRss").textContent = formatBytes(runtime.service_peak_rss_bytes);
129
+ document.getElementById("requestPeak").textContent = formatBytes(runtime.last_request_peak_rss_bytes);
130
+ }
131
+
132
+ async function checkRuntime() {
133
+ try {
134
+ const response = await fetch(apiUrl("/api/runtime"));
135
+ const data = await response.json();
136
+ applyRuntime(data.runtime);
137
+ } catch (error) {
138
+ health.textContent = `runtime check failed: ${error}`;
139
+ }
140
+ }
141
+
142
+ async function switchPrecision(precision) {
143
+ if (precision === selectedPrecision || !availablePrecisions.includes(precision)) return;
144
+ setBusy(true, "Loading");
145
+ const form = new FormData();
146
+ form.append("backend", "onnx_cache");
147
+ form.append("cache_precision", precision);
148
+ form.append("audio_precision", selectedAudioPrecision);
149
+ try {
150
+ const response = await fetch(apiUrl("/api/reload"), { method: "POST", body: form });
151
+ const data = await response.json();
152
+ if (!response.ok) {
153
+ throw new Error(data.detail || response.statusText);
154
+ }
155
+ applyRuntime(data.runtime);
156
+ recordStatus.textContent = "Ready";
157
+ } catch (error) {
158
+ transcript.textContent = `Runtime switch error: ${error}`;
159
+ recordStatus.textContent = "Error";
160
+ } finally {
161
+ setBusy(false, recordStatus.textContent);
162
+ }
163
+ }
164
+
165
+ async function switchAudioPrecision(precision) {
166
+ if (precision === selectedAudioPrecision || !availableAudioPrecisions.includes(precision)) return;
167
+ setBusy(true, "Loading");
168
+ const form = new FormData();
169
+ form.append("backend", "onnx_cache");
170
+ form.append("cache_precision", selectedPrecision);
171
+ form.append("audio_precision", precision);
172
+ try {
173
+ const response = await fetch(apiUrl("/api/reload"), { method: "POST", body: form });
174
+ const data = await response.json();
175
+ if (!response.ok) {
176
+ throw new Error(data.detail || response.statusText);
177
+ }
178
+ applyRuntime(data.runtime);
179
+ recordStatus.textContent = "Ready";
180
+ } catch (error) {
181
+ transcript.textContent = `Runtime switch error: ${error}`;
182
+ recordStatus.textContent = "Error";
183
+ } finally {
184
+ setBusy(false, recordStatus.textContent);
185
+ }
186
+ }
187
+
188
+ async function uploadBlob(blob, name = "recording.wav") {
189
+ try {
190
+ setBusy(true, "Transcribing");
191
+ transcript.textContent = "Working...";
192
+ raw.textContent = "";
193
+ latency.textContent = "--";
194
+ const form = new FormData();
195
+ form.append("audio", blob, name);
196
+ form.append("language", "Chinese");
197
+ form.append("max_new_tokens", maxTokens.value || "128");
198
+ form.append("cache_precision", selectedPrecision);
199
+ form.append("audio_precision", selectedAudioPrecision);
200
+ if (hotwords && hotwords.value.trim()) {
201
+ form.append("hotwords", hotwords.value.trim());
202
+ const strength = hotwordStrengths[selectedHotwordStrength] || hotwordStrengths.normal;
203
+ form.append("hotword_topk", String(strength.topk));
204
+ form.append("hotword_start_boost", String(strength.startBoost));
205
+ form.append("hotword_continuation_boost", String(strength.continuationBoost));
206
+ }
207
+ const response = await fetch(apiUrl("/asr"), { method: "POST", body: form });
208
+ const text = await response.text();
209
+ let data = {};
210
+ try {
211
+ data = text ? JSON.parse(text) : {};
212
+ } catch (error) {
213
+ throw new Error(`Invalid ASR response: ${text.slice(0, 240)}`);
214
+ }
215
+ if (!response.ok) {
216
+ throw new Error(data.detail || response.statusText);
217
+ }
218
+ transcript.textContent = data.text || "(empty)";
219
+ latency.textContent = `${formatSeconds(data.elapsed_seconds)} · ${data.cache_precision || selectedPrecision}`;
220
+ raw.textContent = JSON.stringify(data, null, 2);
221
+ applyRuntime(data.runtime);
222
+ setBusy(false, "Ready");
223
+ } catch (error) {
224
+ transcript.textContent = `Transcription error: ${error}`;
225
+ raw.textContent = "";
226
+ setBusy(false, "Error");
227
+ throw error;
228
+ }
229
+ }
230
+
231
+ function mergeAudioBuffers(buffers) {
232
+ const totalLength = buffers.reduce((sum, buffer) => sum + buffer.length, 0);
233
+ const merged = new Float32Array(totalLength);
234
+ let offset = 0;
235
+ for (const buffer of buffers) {
236
+ merged.set(buffer, offset);
237
+ offset += buffer.length;
238
+ }
239
+ return merged;
240
+ }
241
+
242
+ function appendSilence(samples, sampleRate, durationMs) {
243
+ const silenceLength = Math.max(0, Math.round((sampleRate * durationMs) / 1000));
244
+ if (silenceLength === 0) return samples;
245
+ const padded = new Float32Array(samples.length + silenceLength);
246
+ padded.set(samples);
247
+ return padded;
248
+ }
249
+
250
+ function delay(ms) {
251
+ return new Promise((resolve) => {
252
+ window.setTimeout(resolve, ms);
253
+ });
254
+ }
255
+
256
+ function writeString(view, offset, value) {
257
+ for (let index = 0; index < value.length; index += 1) {
258
+ view.setUint8(offset + index, value.charCodeAt(index));
259
+ }
260
+ }
261
+
262
+ function encodeWav(samples, sampleRate) {
263
+ const bytesPerSample = 2;
264
+ const channelCount = 1;
265
+ const buffer = new ArrayBuffer(44 + samples.length * bytesPerSample);
266
+ const view = new DataView(buffer);
267
+
268
+ writeString(view, 0, "RIFF");
269
+ view.setUint32(4, 36 + samples.length * bytesPerSample, true);
270
+ writeString(view, 8, "WAVE");
271
+ writeString(view, 12, "fmt ");
272
+ view.setUint32(16, 16, true);
273
+ view.setUint16(20, 1, true);
274
+ view.setUint16(22, channelCount, true);
275
+ view.setUint32(24, sampleRate, true);
276
+ view.setUint32(28, sampleRate * channelCount * bytesPerSample, true);
277
+ view.setUint16(32, channelCount * bytesPerSample, true);
278
+ view.setUint16(34, 8 * bytesPerSample, true);
279
+ writeString(view, 36, "data");
280
+ view.setUint32(40, samples.length * bytesPerSample, true);
281
+
282
+ let offset = 44;
283
+ for (const sample of samples) {
284
+ const clamped = Math.max(-1, Math.min(1, sample));
285
+ view.setInt16(offset, clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff, true);
286
+ offset += bytesPerSample;
287
+ }
288
+ return new Blob([view], { type: "audio/wav" });
289
+ }
290
+
291
+ async function stopRecording() {
292
+ if (!audioContext || isStoppingRecording) return;
293
+ isStoppingRecording = true;
294
+ recordStatus.textContent = "Finishing";
295
+ stopBtn.disabled = true;
296
+ await delay(stopTailCaptureMs);
297
+ recordStatus.textContent = "Preparing";
298
+
299
+ if (processorNode) {
300
+ processorNode.disconnect();
301
+ processorNode.onaudioprocess = null;
302
+ }
303
+ if (sourceNode) sourceNode.disconnect();
304
+ if (monitorGain) monitorGain.disconnect();
305
+ if (stream) stream.getTracks().forEach((track) => track.stop());
306
+
307
+ const samples = appendSilence(mergeAudioBuffers(recordedBuffers), recordingSampleRate, wavTailSilenceMs);
308
+ const wav = encodeWav(samples, recordingSampleRate);
309
+ preview.src = URL.createObjectURL(wav);
310
+
311
+ await audioContext.close();
312
+ audioContext = null;
313
+ sourceNode = null;
314
+ processorNode = null;
315
+ monitorGain = null;
316
+ stream = null;
317
+ recordedBuffers = [];
318
+ isStoppingRecording = false;
319
+
320
+ await uploadBlob(wav, "recording.wav");
321
+ }
322
+
323
+ recordBtn.addEventListener("click", async () => {
324
+ try {
325
+ stream = await navigator.mediaDevices.getUserMedia({
326
+ audio: {
327
+ channelCount: 1,
328
+ echoCancellation: true,
329
+ noiseSuppression: true,
330
+ },
331
+ });
332
+ const AudioContextClass = window.AudioContext || window.webkitAudioContext;
333
+ audioContext = new AudioContextClass();
334
+ recordingSampleRate = audioContext.sampleRate;
335
+ recordedBuffers = [];
336
+ isStoppingRecording = false;
337
+ sourceNode = audioContext.createMediaStreamSource(stream);
338
+ processorNode = audioContext.createScriptProcessor(4096, 1, 1);
339
+ monitorGain = audioContext.createGain();
340
+ monitorGain.gain.value = 0;
341
+ processorNode.onaudioprocess = (event) => {
342
+ recordedBuffers.push(new Float32Array(event.inputBuffer.getChannelData(0)));
343
+ };
344
+ sourceNode.connect(processorNode);
345
+ processorNode.connect(monitorGain);
346
+ monitorGain.connect(audioContext.destination);
347
+ recordStatus.textContent = "Recording";
348
+ recordBtn.disabled = true;
349
+ stopBtn.disabled = false;
350
+ uploadBtn.disabled = true;
351
+ } catch (error) {
352
+ transcript.textContent = `Microphone error: ${error}`;
353
+ recordStatus.textContent = "Error";
354
+ }
355
+ });
356
+
357
+ stopBtn.addEventListener("click", () => {
358
+ stopRecording().catch((error) => {
359
+ transcript.textContent = String(error);
360
+ setBusy(false, "Error");
361
+ });
362
+ });
363
+
364
+ uploadBtn.addEventListener("click", async () => {
365
+ const file = fileInput.files && fileInput.files[0];
366
+ if (!file) {
367
+ transcript.textContent = "Choose an audio file first.";
368
+ return;
369
+ }
370
+ preview.src = URL.createObjectURL(file);
371
+ try {
372
+ await uploadBlob(file, file.name);
373
+ } catch (error) {
374
+ transcript.textContent = String(error);
375
+ setBusy(false, "Error");
376
+ }
377
+ });
378
+
379
+ precisionSegments.addEventListener("click", (event) => {
380
+ const button = event.target.closest("button[data-precision]");
381
+ if (button) {
382
+ switchPrecision(button.dataset.precision);
383
+ }
384
+ });
385
+
386
+ audioPrecisionSegments.addEventListener("click", (event) => {
387
+ const button = event.target.closest("button[data-audio-precision]");
388
+ if (button) {
389
+ switchAudioPrecision(button.dataset.audioPrecision);
390
+ }
391
+ });
392
+
393
+ hotwordStrengthSegments.addEventListener("click", (event) => {
394
+ const button = event.target.closest("button[data-hotword-strength]");
395
+ if (!button || !hotwordStrengths[button.dataset.hotwordStrength]) return;
396
+ selectedHotwordStrength = button.dataset.hotwordStrength;
397
+ setHotwordStrengthButtons();
398
+ });
399
+
400
+ function renderGpuList(gpus) {
401
+ const gpuList = document.getElementById("gpuList");
402
+ const gpuSummary = document.getElementById("gpuSummary");
403
+ gpuList.innerHTML = "";
404
+ if (!Array.isArray(gpus) || gpus.length === 0) {
405
+ gpuSummary.textContent = "unified / n/a";
406
+ gpuList.innerHTML = "<div class='empty'>No discrete GPU telemetry.</div>";
407
+ return;
408
+ }
409
+ const totalUsed = gpus.reduce((sum, gpu) => sum + Number(gpu.memory_used_mb || 0), 0);
410
+ const total = gpus.reduce((sum, gpu) => sum + Number(gpu.memory_total_mb || 0), 0);
411
+ gpuSummary.textContent = `${totalUsed.toFixed(0)} / ${total.toFixed(0)} MB`;
412
+ for (const gpu of gpus) {
413
+ const used = Number(gpu.memory_used_mb || 0);
414
+ const capacity = Number(gpu.memory_total_mb || 0);
415
+ const percent = capacity > 0 ? Math.min(100, Math.max(0, (used / capacity) * 100)) : 0;
416
+ const item = document.createElement("div");
417
+ item.className = "gpu-item";
418
+ item.innerHTML = `
419
+ <div class="gpu-line">
420
+ <strong>GPU ${gpu.index}</strong>
421
+ <span>${used.toFixed(0)} / ${capacity.toFixed(0)} MB · ${Number(gpu.utilization_percent || 0).toFixed(0)}%</span>
422
+ </div>
423
+ <div class="bar"><i style="width: ${percent.toFixed(1)}%"></i></div>
424
+ <small>${gpu.name || ""}</small>
425
+ `;
426
+ gpuList.appendChild(item);
427
+ }
428
+ }
429
+
430
+ async function refreshMetrics() {
431
+ try {
432
+ const response = await fetch(apiUrl("/metrics"));
433
+ const data = await response.json();
434
+ document.getElementById("metricStamp").textContent = new Date().toLocaleTimeString();
435
+ document.getElementById("rss").textContent = formatBytes(data.process.rss_bytes);
436
+ document.getElementById("sysmem").textContent = `${formatBytes(data.system.used_bytes)} / ${formatBytes(
437
+ data.system.total_bytes,
438
+ )}`;
439
+ renderGpuList(data.gpu && data.gpu.nvidia);
440
+ applyRuntime(data.runtime);
441
+ document.getElementById("metricsRaw").textContent = JSON.stringify(
442
+ {
443
+ providers: data.runtime && data.runtime.providers,
444
+ cache_graph: data.runtime && data.runtime.cache_graph,
445
+ audio_graph: data.runtime && data.runtime.audio_graph,
446
+ cpu_percent: data.process.cpu_percent,
447
+ },
448
+ null,
449
+ 2,
450
+ );
451
+ } catch (error) {
452
+ document.getElementById("metricsRaw").textContent = String(error);
453
+ }
454
+ }
455
+
456
+ checkRuntime();
457
+ setHotwordStrengthButtons();
458
+ refreshMetrics();
459
+ setInterval(refreshMetrics, 2000);
static/index.html ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Audio8 ASR</title>
7
+ <link rel="stylesheet" href="/static/styles.css" />
8
+ </head>
9
+ <body>
10
+ <main class="shell">
11
+ <section class="workspace">
12
+ <header class="topbar">
13
+ <div>
14
+ <h1>Audio8 ASR</h1>
15
+ <p id="health">loading runtime...</p>
16
+ </div>
17
+ <div class="runtime-pill" id="recordStatus">Ready</div>
18
+ </header>
19
+
20
+ <div class="toolbar">
21
+ <button id="recordBtn" class="primary" type="button">Record</button>
22
+ <button id="stopBtn" type="button" disabled>Stop</button>
23
+ <label>
24
+ <span>Max Tokens</span>
25
+ <input id="maxTokens" type="number" min="8" max="512" value="128" />
26
+ </label>
27
+ </div>
28
+
29
+ <div class="controls">
30
+ <div class="hotword-panel">
31
+ <label>
32
+ <span>Hotwords</span>
33
+ <input id="hotwords" placeholder="Enter hotwords, separated by commas" />
34
+ </label>
35
+ <div class="hotword-options">
36
+ <span>Hotword strength</span>
37
+ <div class="segments compact" id="hotwordStrengthSegments">
38
+ <button type="button" data-hotword-strength="normal">Normal</button>
39
+ <button type="button" data-hotword-strength="strong">Strong</button>
40
+ </div>
41
+ <strong class="risk-note">Strong may force incorrect hotwords, hallucinate, or repeat text.</strong>
42
+ </div>
43
+ </div>
44
+ </div>
45
+
46
+ <div class="precision-row">
47
+ <span>Decoder</span>
48
+ <div class="segments" id="precisionSegments">
49
+ <button type="button" data-precision="fp32">FP32</button>
50
+ <button type="button" data-precision="int8">INT8</button>
51
+ <button type="button" data-precision="int4">INT4</button>
52
+ </div>
53
+ <span class="precision-note" id="precisionNote">--</span>
54
+ </div>
55
+
56
+ <div class="precision-row">
57
+ <span>Audio tower</span>
58
+ <div class="segments" id="audioPrecisionSegments">
59
+ <button type="button" data-audio-precision="fp32">FP32</button>
60
+ <button type="button" data-audio-precision="int8">INT8</button>
61
+ </div>
62
+ <span class="precision-note" id="audioPrecisionNote">--</span>
63
+ </div>
64
+
65
+ <div class="upload-row">
66
+ <input id="fileInput" type="file" accept="audio/*" />
67
+ <button id="uploadBtn" type="button">Transcribe</button>
68
+ </div>
69
+
70
+ <audio id="preview" controls></audio>
71
+
72
+ <section class="result-surface">
73
+ <div class="result-head">
74
+ <h2>Transcript</h2>
75
+ <span id="latency">--</span>
76
+ </div>
77
+ <div class="transcript" id="transcript">Transcript will appear here.</div>
78
+ </section>
79
+
80
+ <details class="raw-box">
81
+ <summary>Raw response</summary>
82
+ <pre id="raw"></pre>
83
+ </details>
84
+ </section>
85
+
86
+ <aside class="monitor">
87
+ <section class="panel">
88
+ <div class="panel-head">
89
+ <h2>Runtime</h2>
90
+ <span id="metricStamp">--</span>
91
+ </div>
92
+ <div class="kv-list">
93
+ <div><span>Backend</span><strong id="backend">--</strong></div>
94
+ <div><span>Precision</span><strong id="precision">--</strong></div>
95
+ <div><span>Process RSS</span><strong id="rss">--</strong></div>
96
+ <div><span>Peak RSS</span><strong id="peakRss">--</strong></div>
97
+ <div><span>Request Peak</span><strong id="requestPeak">--</strong></div>
98
+ <div><span>System Memory</span><strong id="sysmem">--</strong></div>
99
+ </div>
100
+ </section>
101
+
102
+ <section class="panel">
103
+ <div class="panel-head">
104
+ <h2>GPU Memory</h2>
105
+ <span id="gpuSummary">--</span>
106
+ </div>
107
+ <div class="gpu-list" id="gpuList"></div>
108
+ </section>
109
+
110
+ <section class="panel">
111
+ <div class="panel-head">
112
+ <h2>Providers</h2>
113
+ </div>
114
+ <pre id="metricsRaw"></pre>
115
+ </section>
116
+ </aside>
117
+ </main>
118
+
119
+ <script src="/static/app.js?v=hf-staging-error-handling-v1"></script>
120
+ </body>
121
+ </html>
static/styles.css ADDED
@@ -0,0 +1,391 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color-scheme: light;
3
+ --bg: #eef1f5;
4
+ --panel: #ffffff;
5
+ --panel-soft: #f7f9fb;
6
+ --ink: #16202a;
7
+ --muted: #687484;
8
+ --line: #d8dee7;
9
+ --accent: #0b766d;
10
+ --accent-strong: #075c55;
11
+ --accent-ink: #ffffff;
12
+ }
13
+
14
+ * {
15
+ box-sizing: border-box;
16
+ }
17
+
18
+ body {
19
+ margin: 0;
20
+ min-height: 100vh;
21
+ background: var(--bg);
22
+ color: var(--ink);
23
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
24
+ }
25
+
26
+ .shell {
27
+ width: min(1240px, calc(100vw - 32px));
28
+ margin: 20px auto;
29
+ display: grid;
30
+ grid-template-columns: minmax(0, 1fr) 380px;
31
+ gap: 14px;
32
+ }
33
+
34
+ .workspace,
35
+ .panel {
36
+ background: var(--panel);
37
+ border: 1px solid var(--line);
38
+ border-radius: 8px;
39
+ box-shadow: 0 1px 2px rgba(24, 32, 42, 0.05);
40
+ }
41
+
42
+ .workspace {
43
+ padding: 18px;
44
+ }
45
+
46
+ .panel {
47
+ padding: 14px;
48
+ }
49
+
50
+ .monitor {
51
+ display: grid;
52
+ align-content: start;
53
+ gap: 12px;
54
+ }
55
+
56
+ .topbar,
57
+ .panel-head,
58
+ .result-head,
59
+ .precision-row {
60
+ display: flex;
61
+ align-items: center;
62
+ justify-content: space-between;
63
+ gap: 12px;
64
+ }
65
+
66
+ .topbar {
67
+ align-items: flex-start;
68
+ }
69
+
70
+ h1,
71
+ h2,
72
+ p {
73
+ margin: 0;
74
+ }
75
+
76
+ h1 {
77
+ font-size: 26px;
78
+ font-weight: 720;
79
+ letter-spacing: 0;
80
+ }
81
+
82
+ h2 {
83
+ font-size: 16px;
84
+ font-weight: 700;
85
+ letter-spacing: 0;
86
+ }
87
+
88
+ p,
89
+ span,
90
+ label,
91
+ small,
92
+ #metricStamp,
93
+ #latency {
94
+ color: var(--muted);
95
+ }
96
+
97
+ .runtime-pill {
98
+ min-width: 86px;
99
+ padding: 6px 10px;
100
+ border-radius: 8px;
101
+ background: #e9f7f4;
102
+ color: var(--accent-strong);
103
+ text-align: center;
104
+ font-size: 13px;
105
+ font-weight: 700;
106
+ }
107
+
108
+ .toolbar,
109
+ .upload-row,
110
+ .controls {
111
+ margin-top: 16px;
112
+ display: flex;
113
+ flex-wrap: wrap;
114
+ align-items: end;
115
+ gap: 10px;
116
+ }
117
+
118
+ .controls label {
119
+ flex: 1;
120
+ }
121
+
122
+ .controls input {
123
+ width: 100%;
124
+ }
125
+
126
+ .hotword-panel {
127
+ width: 100%;
128
+ display: grid;
129
+ gap: 10px;
130
+ }
131
+
132
+ .hotword-options {
133
+ display: grid;
134
+ grid-template-columns: max-content max-content minmax(220px, 1fr);
135
+ align-items: center;
136
+ gap: 10px;
137
+ padding: 10px;
138
+ border: 1px solid #e6b7b7;
139
+ border-radius: 8px;
140
+ background: #fff7f7;
141
+ }
142
+
143
+ .hotword-options > span {
144
+ font-size: 12px;
145
+ font-weight: 750;
146
+ }
147
+
148
+ .risk-note {
149
+ color: #a23b35;
150
+ font-size: 13px;
151
+ line-height: 1.35;
152
+ }
153
+
154
+ .precision-row {
155
+ justify-content: flex-start;
156
+ margin-top: 16px;
157
+ padding: 10px;
158
+ border: 1px solid var(--line);
159
+ border-radius: 8px;
160
+ background: var(--panel-soft);
161
+ }
162
+
163
+ .precision-note {
164
+ margin-left: auto;
165
+ font-size: 13px;
166
+ }
167
+
168
+ button,
169
+ input {
170
+ height: 38px;
171
+ border-radius: 7px;
172
+ border: 1px solid var(--line);
173
+ font: inherit;
174
+ }
175
+
176
+ button {
177
+ padding: 0 14px;
178
+ background: #ffffff;
179
+ color: var(--ink);
180
+ cursor: pointer;
181
+ }
182
+
183
+ button.primary {
184
+ background: var(--accent);
185
+ border-color: var(--accent);
186
+ color: var(--accent-ink);
187
+ }
188
+
189
+ button:hover:not(:disabled) {
190
+ border-color: var(--accent);
191
+ }
192
+
193
+ button:disabled {
194
+ opacity: 0.45;
195
+ cursor: not-allowed;
196
+ }
197
+
198
+ label {
199
+ display: grid;
200
+ gap: 5px;
201
+ font-size: 12px;
202
+ font-weight: 650;
203
+ }
204
+
205
+ input {
206
+ min-width: 128px;
207
+ padding: 0 10px;
208
+ color: var(--ink);
209
+ background: #ffffff;
210
+ }
211
+
212
+ input[type="file"] {
213
+ min-width: min(360px, 100%);
214
+ padding: 7px 10px;
215
+ }
216
+
217
+ .segments {
218
+ display: inline-grid;
219
+ grid-auto-flow: column;
220
+ grid-auto-columns: 74px;
221
+ gap: 4px;
222
+ padding: 3px;
223
+ border: 1px solid var(--line);
224
+ border-radius: 8px;
225
+ background: #ffffff;
226
+ }
227
+
228
+ .segments.compact {
229
+ grid-auto-columns: 88px;
230
+ }
231
+
232
+ .segments button {
233
+ height: 30px;
234
+ padding: 0;
235
+ border-color: transparent;
236
+ border-radius: 6px;
237
+ font-size: 13px;
238
+ font-weight: 750;
239
+ }
240
+
241
+ .segments button.active {
242
+ background: var(--accent);
243
+ color: var(--accent-ink);
244
+ }
245
+
246
+ audio {
247
+ width: 100%;
248
+ margin-top: 16px;
249
+ }
250
+
251
+ .result-surface {
252
+ margin-top: 16px;
253
+ }
254
+
255
+ .transcript {
256
+ min-height: 176px;
257
+ margin-top: 10px;
258
+ padding: 16px;
259
+ border: 1px solid var(--line);
260
+ border-radius: 8px;
261
+ background: #fbfcfe;
262
+ font-size: 22px;
263
+ line-height: 1.55;
264
+ word-break: break-word;
265
+ }
266
+
267
+ .raw-box {
268
+ margin-top: 12px;
269
+ }
270
+
271
+ .raw-box summary {
272
+ cursor: pointer;
273
+ color: var(--muted);
274
+ font-size: 13px;
275
+ font-weight: 650;
276
+ }
277
+
278
+ pre {
279
+ max-height: 240px;
280
+ overflow: auto;
281
+ margin: 10px 0 0;
282
+ padding: 12px;
283
+ border-radius: 8px;
284
+ background: #101820;
285
+ color: #d8e7ef;
286
+ font-size: 12px;
287
+ line-height: 1.45;
288
+ white-space: pre-wrap;
289
+ }
290
+
291
+ .kv-list {
292
+ margin-top: 12px;
293
+ display: grid;
294
+ grid-template-columns: repeat(2, minmax(0, 1fr));
295
+ gap: 8px;
296
+ }
297
+
298
+ .kv-list div {
299
+ min-height: 62px;
300
+ display: grid;
301
+ align-content: center;
302
+ gap: 4px;
303
+ padding: 10px;
304
+ border: 1px solid var(--line);
305
+ border-radius: 8px;
306
+ background: var(--panel-soft);
307
+ }
308
+
309
+ .kv-list span,
310
+ .gpu-line span {
311
+ font-size: 12px;
312
+ }
313
+
314
+ .kv-list strong {
315
+ font-size: 14px;
316
+ overflow-wrap: anywhere;
317
+ }
318
+
319
+ .gpu-list {
320
+ display: grid;
321
+ gap: 10px;
322
+ margin-top: 12px;
323
+ }
324
+
325
+ .gpu-item {
326
+ display: grid;
327
+ gap: 6px;
328
+ }
329
+
330
+ .gpu-line {
331
+ display: flex;
332
+ justify-content: space-between;
333
+ gap: 10px;
334
+ }
335
+
336
+ .bar {
337
+ height: 8px;
338
+ border-radius: 999px;
339
+ background: #e4e9f0;
340
+ overflow: hidden;
341
+ }
342
+
343
+ .bar i {
344
+ display: block;
345
+ height: 100%;
346
+ background: var(--accent);
347
+ }
348
+
349
+ .empty {
350
+ color: var(--muted);
351
+ font-size: 13px;
352
+ }
353
+
354
+ @media (max-width: 920px) {
355
+ .shell {
356
+ grid-template-columns: 1fr;
357
+ }
358
+
359
+ .monitor {
360
+ grid-template-columns: 1fr;
361
+ }
362
+ }
363
+
364
+ @media (max-width: 560px) {
365
+ .shell {
366
+ width: calc(100vw - 18px);
367
+ margin: 9px auto;
368
+ }
369
+
370
+ .workspace,
371
+ .panel {
372
+ padding: 12px;
373
+ }
374
+
375
+ h1 {
376
+ font-size: 22px;
377
+ }
378
+
379
+ .transcript {
380
+ font-size: 18px;
381
+ }
382
+
383
+ .hotword-options {
384
+ grid-template-columns: 1fr;
385
+ align-items: stretch;
386
+ }
387
+
388
+ .segments.compact {
389
+ grid-auto-columns: minmax(0, 1fr);
390
+ }
391
+ }
transcribe_file.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from asr_onnx_runtime import OnnxAsrEngine, OnnxCacheAsrEngine
11
+
12
+
13
+ APP_DIR = Path(__file__).resolve().parent
14
+ BUNDLE_CANDIDATES = (
15
+ "model_bundle",
16
+ )
17
+
18
+
19
+ def default_bundle_dir() -> Path:
20
+ env_value = os.environ.get("AUDIO8_ASR_BUNDLE")
21
+ if env_value:
22
+ return Path(env_value).expanduser()
23
+ for name in BUNDLE_CANDIDATES:
24
+ candidate = APP_DIR / name
25
+ if (candidate / "metadata.json").exists():
26
+ return candidate
27
+ return APP_DIR / "model_bundle"
28
+
29
+
30
+ def transcribe_file(
31
+ audio_path: str | Path,
32
+ *,
33
+ bundle_dir: str | Path | None = None,
34
+ backend: str = "onnx_cache",
35
+ cache_precision: str = "int8",
36
+ audio_precision: str = "int8",
37
+ language: str | None = "Chinese",
38
+ max_new_tokens: int = 128,
39
+ provider: str = "CPUExecutionProvider",
40
+ threads: int | None = None,
41
+ hotwords: str | list[str] | None = None,
42
+ hotword_topk: int = 50,
43
+ hotword_start_boost: float = 6.0,
44
+ hotword_continuation_boost: float = 8.0,
45
+ ) -> dict[str, Any]:
46
+ bundle_path = Path(bundle_dir).expanduser() if bundle_dir is not None else default_bundle_dir()
47
+ audio_file = Path(audio_path).expanduser()
48
+ if backend == "onnx":
49
+ engine = OnnxAsrEngine(
50
+ bundle_path,
51
+ provider=provider,
52
+ intra_op_num_threads=threads,
53
+ audio_precision=audio_precision,
54
+ )
55
+ elif backend == "onnx_cache":
56
+ engine = OnnxCacheAsrEngine(
57
+ bundle_path,
58
+ provider=provider,
59
+ intra_op_num_threads=threads,
60
+ cache_precision=cache_precision,
61
+ audio_precision=audio_precision,
62
+ )
63
+ else:
64
+ raise ValueError(f"Unsupported backend: {backend}")
65
+ return engine.transcribe(
66
+ audio_file.read_bytes(),
67
+ language=language,
68
+ max_new_tokens=max_new_tokens,
69
+ hotwords=hotwords,
70
+ hotword_topk=hotword_topk,
71
+ hotword_start_boost=hotword_start_boost,
72
+ hotword_continuation_boost=hotword_continuation_boost,
73
+ )
74
+
75
+
76
+ def parse_args() -> argparse.Namespace:
77
+ parser = argparse.ArgumentParser(description="Transcribe one audio file with the Audio8 ASR ONNX runtime.")
78
+ parser.add_argument("audio", type=Path, help="Audio file path. WAV is recommended; librosa/soundfile handle common formats.")
79
+ parser.add_argument("--bundle_dir", type=Path, default=default_bundle_dir())
80
+ parser.add_argument("--backend", choices=["onnx_cache", "onnx"], default=os.environ.get("ASR_BACKEND", "onnx_cache"))
81
+ parser.add_argument("--cache_precision", choices=["fp32", "int8", "int4", "auto"], default=os.environ.get("ASR_CACHE_PRECISION", "int8"))
82
+ parser.add_argument("--audio_precision", choices=["fp32", "int8", "auto"], default=os.environ.get("ASR_AUDIO_PRECISION", "int8"))
83
+ parser.add_argument("--language", default="Chinese")
84
+ parser.add_argument("--max_new_tokens", type=int, default=128)
85
+ parser.add_argument("--provider", default=os.environ.get("ORT_PROVIDER", "CPUExecutionProvider"))
86
+ parser.add_argument("--threads", type=int, default=int(os.environ.get("ORT_THREADS", "0")))
87
+ parser.add_argument("--hotwords", default=None, help="Comma or Chinese-comma separated hotwords, disabled when omitted.")
88
+ parser.add_argument("--hotword_topk", type=int, default=50)
89
+ parser.add_argument("--hotword_start_boost", type=float, default=6.0)
90
+ parser.add_argument("--hotword_continuation_boost", type=float, default=8.0)
91
+ parser.add_argument("--json", action="store_true", help="Print the full result JSON instead of only transcript text.")
92
+ return parser.parse_args()
93
+
94
+
95
+ def main() -> None:
96
+ args = parse_args()
97
+ result = transcribe_file(
98
+ args.audio,
99
+ bundle_dir=args.bundle_dir,
100
+ backend=args.backend,
101
+ cache_precision=args.cache_precision,
102
+ audio_precision=args.audio_precision,
103
+ language=args.language or None,
104
+ max_new_tokens=args.max_new_tokens,
105
+ provider=args.provider,
106
+ threads=args.threads if args.threads > 0 else None,
107
+ hotwords=args.hotwords or None,
108
+ hotword_topk=args.hotword_topk,
109
+ hotword_start_boost=args.hotword_start_boost,
110
+ hotword_continuation_boost=args.hotword_continuation_boost,
111
+ )
112
+ if args.json:
113
+ print(json.dumps(result, ensure_ascii=False, indent=2))
114
+ else:
115
+ print(result.get("text", ""))
116
+
117
+
118
+ if __name__ == "__main__":
119
+ main()