Initial release of Notesoop-STT-Ko-v1 (Private)
Browse files- .gitattributes +2 -34
- .gitignore +12 -0
- LICENSE +176 -0
- README.md +248 -0
- checkpoints/best_reader_inference.pt +3 -0
- configs/config.yaml +84 -0
- data/vocab/korean_lm.bin +3 -0
- data/vocab/vector_bank.pt +3 -0
- data/vocab/vector_bank_25k.pt +3 -0
- data/vocab/vocab.json +0 -0
- data/vocab/vocab.txt +0 -0
- data/vocab/vocab_25k.json +0 -0
- data/vocab/vocab_25k.txt +0 -0
- requirements.txt +11 -0
- run_inference.py +399 -0
- sample_audio.wav +0 -0
- src/dataset.py +774 -0
- src/decoder.py +336 -0
- src/model.py +783 -0
- src/step2_extract_features.py +1476 -0
- transcribe.py +83 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,3 @@
|
|
| 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 |
-
*.
|
| 24 |
-
*.
|
| 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 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.wav filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.gitignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python caches
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# OS artifacts
|
| 7 |
+
.DS_Store
|
| 8 |
+
Thumbs.db
|
| 9 |
+
|
| 10 |
+
# Logs & temp files
|
| 11 |
+
*.log
|
| 12 |
+
*.tmp
|
LICENSE
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
README.md
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
language:
|
| 3 |
+
- ko
|
| 4 |
+
license: apache-2.0
|
| 5 |
+
tags:
|
| 6 |
+
- audio
|
| 7 |
+
- speech-recognition
|
| 8 |
+
- automatic-speech-recognition
|
| 9 |
+
- conformer
|
| 10 |
+
- ctc
|
| 11 |
+
- apple-silicon
|
| 12 |
+
- metal
|
| 13 |
+
- on-device
|
| 14 |
+
- korean
|
| 15 |
+
- embeddinggemma-2
|
| 16 |
+
- notesoop
|
| 17 |
+
pipeline_tag: automatic-speech-recognition
|
| 18 |
+
metrics:
|
| 19 |
+
- cer
|
| 20 |
+
- wer
|
| 21 |
+
library_name: pytorch
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
# Notesoop-STT-Ko-v1
|
| 25 |
+
|
| 26 |
+
<p align="center">
|
| 27 |
+
<img src="https://img.shields.io/badge/Language-Korean%20(한국어)-blue" alt="Language" />
|
| 28 |
+
<img src="https://img.shields.io/badge/Speedup-39.9x%20Real--time-green" alt="Speedup" />
|
| 29 |
+
<img src="https://img.shields.io/badge/Architecture-Conformer%20CTC%20(21.5M)-orange" alt="Architecture" />
|
| 30 |
+
<img src="https://img.shields.io/badge/VRAM-1.2GB%20~%201.5GB-purple" alt="VRAM" />
|
| 31 |
+
<img src="https://img.shields.io/badge/Platform-Apple%20Silicon%20(PyTorch%20MPS)-black" alt="Platform" />
|
| 32 |
+
<img src="https://img.shields.io/badge/License-Apache%202.0-lightgrey" alt="License" />
|
| 33 |
+
</p>
|
| 34 |
+
|
| 35 |
+
<p align="center">
|
| 36 |
+
<a href="https://apps.apple.com/kr/app/notesoop/id6760303593" target="_blank"><img src="https://img.shields.io/badge/App_Store-%EB%85%B8%ED%8A%B8%EC%88%B2_%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C-0D96F6?style=for-the-badge&logo=apple&logoColor=white" alt="Download on the App Store" /></a> <a href="https://play.google.com/store/apps/details?id=com.phsoft.notesoop&utm_source=ig&utm_medium=social&utm_content=link_in_bio&fbclid=PAdGRleAS711NwZG9mAmV4dG4DYWVtAjExAHNydGMGYXBwX2lkDzEyNDAyNDU3NDI4NzQxNAABpxFu1PSQrzp-9WgAihbuFrY4gre7MP6MH5Xx4lfKa9FjndnyVoMXb8awipoa_aem_5ccpc4qbd5STxaPUlQif3g" target="_blank"><img src="https://img.shields.io/badge/Google_Play-%EB%85%B8%ED%8A%B8%EC%88%B2_%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C-01875F?style=for-the-badge&logo=googleplay&logoColor=white" alt="Get it on Google Play" /></a>
|
| 37 |
+
</p>
|
| 38 |
+
|
| 39 |
+
**Notesoop-STT-Ko-v1**은 **노트숲(Notesoop)**에서 개발한 Apple Silicon(PyTorch MPS) 가속 한국어 온디바이스 음성인식(ASR) 모델입니다.
|
| 40 |
+
|
| 41 |
+
동결된(Frozen) `EmbeddingGemma-2` 오디오 인코더의 256차원 Matryoshka 표현(MRL) 위에 21.5M 파라미터 규모의 경량 10-Layer Conformer CTC 리더와 C++ KenLM 3-Gram 디코더를 결합했습니다. 약 1.2~1.5GB 수준의 낮은 VRAM 점유율로 **실시간 대비 약 40배속(`RTF 0.0251`, 41분 분량 음성을 약 63초에 처리)**의 추론 성능을 제공합니다.
|
| 42 |
+
|
| 43 |
+
> 본 모델(**Notesoop-STT-Ko-v1**)은 **올해 안에 노트숲(Notesoop) 앱에서도 만나보실 수 있습니다.**
|
| 44 |
+
|
| 45 |
+
---
|
| 46 |
+
|
| 47 |
+
## Background & Design Goals
|
| 48 |
+
|
| 49 |
+
노트숲은 **"학습의 비효율을 줄여, 사용자가 도구가 아닌 배움에 집중할 수 있는 경험을 만든다"**는 미션 아래 흩어진 학습 도구와 AI를 자연스럽게 연결하는 기술을 연구하고 있습니다.
|
| 50 |
+
|
| 51 |
+
실제 학습 환경에서는 수십 분에서 수 시간 분량의 강의 및 학습 음성을 다루게 됩니다. 이때 네트워크 상태에 의존하거나 긴 변환 시간을 기다려야 하고, 무거운 모델 때문에 기기 리소스가 잠식되어 정작 PDF 열람이나 필기 같은 본연의 학습 활동이 방해받는 것은 큰 비효율입니다. **Notesoop-STT-Ko-v1**은 기술적 복잡함은 모델 내부로 숨기고 사용자에게는 끊김 없는 학습 환경을 제공하기 위해 다음 세 가지 목표를 중심으로 설계되었습니다.
|
| 52 |
+
|
| 53 |
+
* **실질적인 학습 효율 (Practicality & Speed):** 긴 녹음 파일도 대기 시간 없이 즉시 복습과 정리에 활용할 수 있도록, 자기회귀(Autoregressive) 디코딩을 배제한 단일 패스 CTC 구조와 GPU-CPU 비동기 파이프라인을 적용했습니다.
|
| 54 |
+
* **모바일 및 온디바이스 최적화 (Simplicity & Accessibility):** 별도의 서버나 고사양 장비 없이 태블릿·스마트폰 등 제한된 연산 자원을 가진 모바일 디바이스에서도 충분한 추론 속도를 제공할 수 있도록, 핵심 리더 모델을 21.5M 파라미터 규모로 경량화하고 메모리 점유율을 최소화했습니다.
|
| 55 |
+
* **열린 기술 실험 (Open Ecosystem):** 누구나 온디바이스 환경에서 한국어 음성인식 기술을 부담 없이 실험하고 더 나은 학습 경험을 만드는 데 활용할 수 있도록, 모델 가중치와 PyTorch MPS 고속 추론 파이프라인 전체를 Apache-2.0 라이선스로 공개합니다.
|
| 56 |
+
|
| 57 |
+
---
|
| 58 |
+
|
| 59 |
+
## Model Specifications
|
| 60 |
+
|
| 61 |
+
| 항목 | 상세 사양 |
|
| 62 |
+
| :--- | :--- |
|
| 63 |
+
| **Audio Encoder** | `google/embeddinggemma-2` (Frozen 300M Audio Tower, 16kHz Mono, 128-Mel, 40ms/token) |
|
| 64 |
+
| **Feature Projection** | Matryoshka Representation Learning (MRL) 768-dim → 256-dim (L2 Normalized) |
|
| 65 |
+
| **Acoustic Reader** | 10-Layer Conformer (`d_model=384`, `nhead=6`, `ffn_dim=1536`, **21.5M parameters**) |
|
| 66 |
+
| **Output Head** | Cosine Similarity CTC Head (Frozen 25,000-token Spherical Vector Bank) |
|
| 67 |
+
| **Vocabulary** | 25,000 한국어 형태소 + 음절 Fallback (`<blank>` ID = 0) |
|
| 68 |
+
| **LM Decoder** | C++ KenLM 3-Gram Beam Search (`alpha=0.12`, `beta=0.40`) / Greedy CTC 선택 가능 |
|
| 69 |
+
| **Supported Backends** | PyTorch MPS (`bfloat16`), CUDA / CPU |
|
| 70 |
+
| **Training Data** | AI Hub 한국어 강의 음성 데이터 (약 300GB) |
|
| 71 |
+
| **License** | Apache-2.0 |
|
| 72 |
+
|
| 73 |
+
---
|
| 74 |
+
|
| 75 |
+
## Architecture
|
| 76 |
+
|
| 77 |
+
Notesoop-STT-Ko-v1은 비자기회귀(Non-Autoregressive) Conformer CTC 구조와 사전 정렬된 벡터 공간 매칭을 결합하여 파라미터 수와 추론 지연을 동시에 줄였습니다.
|
| 78 |
+
|
| 79 |
+
```mermaid
|
| 80 |
+
graph TD
|
| 81 |
+
A["Audio Input (16kHz Mono)"] --> B["Chunking (16.0s window, 1.0s overlap)"]
|
| 82 |
+
B --> C["EmbeddingGemma-2 Audio Tower (Frozen, 300M)"]
|
| 83 |
+
C --> D["MRL 256-d Projection & L2 Norm"]
|
| 84 |
+
D --> E["10-Layer Conformer Reader (21.5M)"]
|
| 85 |
+
E --> F["Cosine CTC Head (25,000 Vector Bank)"]
|
| 86 |
+
F --> G["Async C++ KenLM 3-Gram Beam Search"]
|
| 87 |
+
G --> H["Overlap Deduplication & Final Transcript"]
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
1. **MRL 256차원 특징 추출 및 구형 벡터 매칭:**
|
| 91 |
+
`EmbeddingGemma-2` 오디오 인코더의 768차원 출력을 MRL 기반 256차원으로 축소·정규화하여 사용합니다. 출력단에서는 25,000개 한국어 형태소·음절 임베딩으로 구성된 동결 구형 벡터 뱅크(Spherical Vector Bank)와의 코사인 유사도를 통해 CTC 로짓을 계산합니다.
|
| 92 |
+
2. **경량 Conformer Reader (21.5M):**
|
| 93 |
+
음향 특징을 텍스트 토큰 공간으로 매핑하는 핵심 리더 모델을 2,150만 파라미터 규모로 경량화하여 온디바이스 환경의 메모리 대역폭 병목을 최소화했습니다.
|
| 94 |
+
3. **Apple Silicon MPS 고속 가속:**
|
| 95 |
+
PyTorch MPS 환경에서 불필요한 FP32/FP16 형변환 오버헤드를 제거한 `bfloat16` 네이티브 텐서 연산 경로를 적용하여, M1/M2/M3/M4 Mac에서 대기 시간 없이 실시간 대비 약 40배속의 빠른 추론 성능을 발휘합니다.
|
| 96 |
+
4. **GPU-CPU 비동기 파이프라이닝:**
|
| 97 |
+
GPU가 다음 오디오 청크 배치를 연산하는 동안 CPU 스레드 풀에서 C++ KenLM 3-Gram 언어 모델 빔 서치와 청크 경계 중복 제거(Overlap Deduplication)를 병렬로 처리합니다.
|
| 98 |
+
|
| 99 |
+
---
|
| 100 |
+
|
| 101 |
+
## Data
|
| 102 |
+
|
| 103 |
+
**Notesoop-STT-Ko-v1**은 실제 학습 및 교육 현장의 음향·언어적 특성을 반영하기 위해 **AI Hub의 약 300GB 분량 한국어 강의 음성 데이터**로 학습되었습니다.
|
| 104 |
+
|
| 105 |
+
* **실전 강의 도메인 특화:** 약 300GB 규모의 방대한 실제 강의 음성을 학습에 활용하여, 정제된 낭독체뿐만 아니라 강의 현장의 발화 속도 변화, 설명조 어투, 학술·전공 용어, 장시간 연속 발화 환경에서도 안정적인 인식 성능을 제공합니다.
|
| 106 |
+
* **형태소·음절 하이브리드 토크나이징:** 한국어 교착어 특성에 맞춰 자주 쓰이는 표면 형태소(Surface Morpheme)와 음절 단위 Fallback을 결합한 25,000개 어휘 사전을 구축하여 미등록 어휘(OOV) 손실을 최소화했습니다.
|
| 107 |
+
|
| 108 |
+
---
|
| 109 |
+
|
| 110 |
+
## Benchmark
|
| 111 |
+
|
| 112 |
+
`Notesoop-STT-Ko-v1`의 성능을 **(1) 공인 표준 한국어 학술 벤치마크**와 **(2) 실제 대학 전공 강의 롱폼 음성** 두 가지 환경에서 실측한 결과입니다. (Apple M1 Pro 16GB 기준)
|
| 113 |
+
|
| 114 |
+
### 1. 표준 한국어 STT 벤치마크 (Zeroth-Korean Test Set, 457개 문장)
|
| 115 |
+
한국어 음성인식 학술 평가의 공식 표준 데이터셋인 `Bingsu/zeroth-korean` 테스트 분할(총 457개 발화 문장, 1시간 11분 27초 분량)에 대한 전수 평가 결과입니다.
|
| 116 |
+
|
| 117 |
+
| 데이터셋 | 총 오디오 길이 | 전체 전사 시간 ⏱️ | 체감 속도 ⚡ | RTF | 초당 처리량 (Throughput) | 음절 오차율 (CER) 🎯 | 단어 오차율 (WER) |
|
| 118 |
+
| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
|
| 119 |
+
| **Zeroth-Korean Test (457문장)** | **1시간 11분 (4,287초)** | **2분 30초 (150.7초)** | **28.45배속** | **0.0352** | **3.03 문장/초** | **16.21%** | 45.40% |
|
| 120 |
+
|
| 121 |
+
* **초당 3문장 이상의 초고속 전사**: 1시간 11분 분량의 방대한 오디오를 단 2분 30초 만에 완주하며, 낭독체 음성 환경에서 **16.21%의 준수한 음절 오차율(CER)**을 달성합니다.
|
| 122 |
+
* **실제 전사 샘플 예시**:
|
| 123 |
+
* **정답 (REF)**: *"한 잔의 커피가 소비자에게 전해지기 위해선 여러 차례의 공정을 거쳐야 한다"*
|
| 124 |
+
* **예측 (HYP)**: *"한 잔의 커피가 소비자에게 전해지기 위해선 여러 차례의 공정을 거쳐야 한다"* (CER: **0.00%**)
|
| 125 |
+
* **정답 (REF)**: *"또한 박 전 대통령 측은 앞으로 검찰 수사와 관련해 수사 내용에 대한 입장뿐 아니라 변호인단의 활동 상황 등도 소상하게 공개한다는 방침이다"*
|
| 126 |
+
* **예측 (HYP)**: *"또한 박 전 대통령 주변에 앞으로 검찰수사와 관련된 수사 내용에 대한 입장뿐 아니라 변호인단의 활동 상황 등도 소상하게 공개한다는 방식이다"* (CER: **10.39%**)
|
| 127 |
+
|
| 128 |
+
---
|
| 129 |
+
|
| 130 |
+
### 2. 실전 전공 강의 녹음 (41분 43초, Field Test)
|
| 131 |
+
대학 강의 환경(배경 잡음, 반향, 말더듬 등이 포함된 실제 41분 43초 녹음 음성)에서 글로벌 표준 ASR 모델들과 비교 측정한 실측 결과입니다.
|
| 132 |
+
|
| 133 |
+
| Model | Backend / Device | Processing Time | Speedup | RTF | CER (%) 🎯 | WER (%) | Host RAM | GPU Memory (VRAM) 🖥️ |
|
| 134 |
+
| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
|
| 135 |
+
| **Notesoop-STT-Ko-v1** | **PyTorch MPS (`bf16`)** | **1분 2초 (62.7s)** | **39.9x** | **0.0251** | **22.46** | 39.25 | **1,076 MB** | 1,537 MB |
|
| 136 |
+
| Whisper-medium | MPS | 4분 11초 (251.5s) | 10.0x | 0.1005 | 26.68 | 45.82 | 1,050 MB | 3,467 MB |
|
| 137 |
+
| Whisper-large-v3 | MPS | 11분 45초 (704.9s) | 3.6x | 0.2816 | 25.55 | 42.10 | 1,243 MB | 6,325 MB |
|
| 138 |
+
|
| 139 |
+
* **`Whisper-large-v3` 대비 11.2배 고속 & 더 우수한 정확도:** Whisper-large-v3(11분 45초, CER 25.55%) 대비 **약 11.2배 빠른 속도(1분 2초)**와 **더 낮은 음절 오차율(CER 22.46%, 3.09%p 향상)**을 동시에 제공합니다.
|
| 140 |
+
* **1/4 수준의 경량 VRAM (1.2~1.5GB):** Whisper-large-v3(6.3GB)의 1/4도 안 되는 가벼운 메모리 점유율 덕분에, 기본형 맥북 에어/프로(8GB/16GB)에서도 발열과 팬 소음 없이 다른 학습 앱과 동시에 쾌적하게 구동됩니다.
|
| 141 |
+
* **비자기회귀(Non-Autoregressive) Conformer의 강점:** 문장을 한 토큰씩 순차 생성하는 Whisper와 달리, Conformer CTC 구조로 오디오 전체 음향 스펙트로그램을 단일 패스($O(T)$)로 병렬 처리하여 장시간 음성에서도 처리 속도가 기하급수적으로 빨라집니다.
|
| 142 |
+
|
| 143 |
+
---
|
| 144 |
+
|
| 145 |
+
## Quickstart
|
| 146 |
+
|
| 147 |
+
### 1. Installation
|
| 148 |
+
|
| 149 |
+
macOS (Apple Silicon M1 이상 권장) 및 Python 3.10+ 환경을 권장합니다. 오디오 디코딩을 위해 시스템에 `ffmpeg`가 설치되어 있어야 합니다(`brew install ffmpeg`).
|
| 150 |
+
|
| 151 |
+
```bash
|
| 152 |
+
git clone https://huggingface.co/Phsoft-ai/Notesoop-STT-Ko-v1
|
| 153 |
+
cd Notesoop-STT-Ko-v1
|
| 154 |
+
|
| 155 |
+
# 의존성 패키지 설치
|
| 156 |
+
pip install -r requirements.txt
|
| 157 |
+
|
| 158 |
+
# KenLM 3-Gram 디코더 설치
|
| 159 |
+
pip install https://github.com/kpu/kenlm/archive/master.zip
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
### 2. CLI Usage
|
| 163 |
+
|
| 164 |
+
`transcribe.py`를 통해 단일 명령어로 오디오 파일을 전사할 수 있습니다. (`.wav`, `.m4a`, `.mp3`, `.flac` 지원)
|
| 165 |
+
|
| 166 |
+
```bash
|
| 167 |
+
# 기본 실행 (PyTorch MPS 백엔드, batch_size=4)
|
| 168 |
+
python transcribe.py sample_audio.wav
|
| 169 |
+
|
| 170 |
+
# 장시간 강의 오디오 전사 및 결과 파일 저장
|
| 171 |
+
python transcribe.py my_lecture.m4a --batch-size 4 --chunk-sec 16.0 --output transcript.txt
|
| 172 |
+
|
| 173 |
+
# KenLM 빔 서치 비활성화 (Greedy CTC 디코딩)
|
| 174 |
+
python transcribe.py sample_audio.wav --no-kenlm
|
| 175 |
+
```
|
| 176 |
+
|
| 177 |
+
### 3. Python API
|
| 178 |
+
|
| 179 |
+
```python
|
| 180 |
+
from run_inference import EmbeddingGemmaSTT
|
| 181 |
+
|
| 182 |
+
stt = EmbeddingGemmaSTT(
|
| 183 |
+
device="mps",
|
| 184 |
+
use_kenlm=True,
|
| 185 |
+
lm_alpha=0.12,
|
| 186 |
+
lm_beta=0.40,
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
transcript, rtf = stt.transcribe(
|
| 190 |
+
"sample_audio.wav",
|
| 191 |
+
chunk_sec=16.0,
|
| 192 |
+
overlap_sec=1.0,
|
| 193 |
+
batch_size=4,
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
print(f"RTF: {rtf:.4f} ({1/rtf:.1f}x real-time)")
|
| 197 |
+
print(transcript)
|
| 198 |
+
```
|
| 199 |
+
|
| 200 |
+
---
|
| 201 |
+
|
| 202 |
+
## Repository Structure
|
| 203 |
+
|
| 204 |
+
```text
|
| 205 |
+
Notesoop-STT-Ko-v1/
|
| 206 |
+
├── README.md # Model Card 및 기술 문서
|
| 207 |
+
├── configs/
|
| 208 |
+
│ └── config.yaml # 모델 아키텍처 및 하이퍼파라미터 설정
|
| 209 |
+
├── checkpoints/
|
| 210 |
+
│ └── best_reader_inference.pt # PyTorch Conformer Reader 가중치 (137MB)
|
| 211 |
+
├── data/
|
| 212 |
+
│ └── vocab/
|
| 213 |
+
│ ├── vocab_25k.json # 25,000 한국어 형태소/음절 어휘 사전
|
| 214 |
+
│ ├── vector_bank_25k.pt # MRL 256차원 동결 벡터 뱅크
|
| 215 |
+
│ └── korean_lm.bin # KenLM 3-Gram 한국어 언어 모델 (82.7MB)
|
| 216 |
+
├── src/
|
| 217 |
+
│ ├── model.py # EmbeddingGemmaCTCReader 모델 정의
|
| 218 |
+
│ ├── dataset.py # 한국어 형태소 토크나이저 및 정규화
|
| 219 |
+
│ ├── decoder.py # KenLM 빔 서치 디코더 래퍼
|
| 220 |
+
│ └── step2_extract_features.py # EmbeddingGemma-2 오디오 특징 추출기
|
| 221 |
+
├── run_inference.py # 추론 파이프라인 및 Python API
|
| 222 |
+
├── transcribe.py # CLI 실행 스크립트
|
| 223 |
+
└── sample_audio.wav # 테스트용 샘플 오디오
|
| 224 |
+
```
|
| 225 |
+
|
| 226 |
+
---
|
| 227 |
+
|
| 228 |
+
## License & Citation
|
| 229 |
+
|
| 230 |
+
본 모델과 코드는 **[Apache License 2.0](LICENSE)** 하에 공개되어 연구 및 상업적 용도로 자유롭게 활용할 수 있습니다.
|
| 231 |
+
|
| 232 |
+
```bibtex
|
| 233 |
+
@misc{notesoop2026sttkov1,
|
| 234 |
+
author = {Notesoop AI Team},
|
| 235 |
+
title = {Notesoop-STT-Ko-v1: Lightweight Korean On-Device Speech Recognition},
|
| 236 |
+
year = {2026},
|
| 237 |
+
publisher = {Hugging Face},
|
| 238 |
+
howpublished = {\url{https://huggingface.co/Phsoft-ai/Notesoop-STT-Ko-v1}},
|
| 239 |
+
note = {Apple Silicon MPS Accelerated Korean ASR}
|
| 240 |
+
}
|
| 241 |
+
```
|
| 242 |
+
|
| 243 |
+
---
|
| 244 |
+
|
| 245 |
+
<p align="center">
|
| 246 |
+
<b>Notesoop (노트숲)</b> — 학습의 비효율을 줄여, 도구가 아닌 배움에 집중할 수 있는 경험을 만듭니다.<br>
|
| 247 |
+
음성 기록과 PDF 필기 연동, 핵심 정리, 복습 문제 생성까지 하나로 연결된 학습 환경을 노트숲 앱에서 만나보세요.
|
| 248 |
+
</p>
|
checkpoints/best_reader_inference.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:879b41bb654da9384e21ccf04076f065f9bcead96fb104c0835ddd8efb326c8c
|
| 3 |
+
size 137381751
|
configs/config.yaml
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ==============================================================================
|
| 2 |
+
# EmbeddingGemma-2 Korean STT Pipeline Configuration
|
| 3 |
+
# Optimized for Apple Silicon M2 Pro (16GB Unified Memory) & Mobile Deployment
|
| 4 |
+
# ==============================================================================
|
| 5 |
+
|
| 6 |
+
system:
|
| 7 |
+
device: "mps" # Forward & Matmul device (auto falls back to cpu if mps unavailable)
|
| 8 |
+
ctc_device: "cpu" # CTC Loss device (MPS CTCLoss has known stability issues)
|
| 9 |
+
precision: "bfloat16" # "bfloat16" or "float32" (NEVER float16 due to NaN overflow)
|
| 10 |
+
seed: 42
|
| 11 |
+
|
| 12 |
+
model:
|
| 13 |
+
name: "google/embeddinggemma-2"
|
| 14 |
+
original_dim: 768 # Full output dimension of EmbeddingGemma-2
|
| 15 |
+
embedding_dim: 256 # Matryoshka Representation Learning (MRL) sliced dim
|
| 16 |
+
normalize_embeddings: true # L2 normalization (F.normalize)
|
| 17 |
+
|
| 18 |
+
reader:
|
| 19 |
+
input_dim: 256 # Cached audio feature dimension
|
| 20 |
+
output_dim: 256 # Vector Bank matching dimension
|
| 21 |
+
d_model: 384 # Expanded Conformer latent dimension
|
| 22 |
+
nhead: 6 # 6 attention heads (64 dim per head)
|
| 23 |
+
num_layers: 10 # 10-layer Conformer (~21.5M params total)
|
| 24 |
+
dim_feedforward: 1536 # 4x d_model FFN dimension
|
| 25 |
+
dropout: 0.1
|
| 26 |
+
spec_augment: true
|
| 27 |
+
trainable_vector_bank: false # Strictly FROZEN to preserve EmbeddingGemma-2 Zero-Shot OTA Hot-Swap anchor
|
| 28 |
+
init_temperature: 0.05
|
| 29 |
+
max_params: 25000000
|
| 30 |
+
|
| 31 |
+
audio:
|
| 32 |
+
sample_rate: 16000 # 16 kHz Mono
|
| 33 |
+
n_mels: 128 # Mel filterbanks matching Gemma4AudioFeatureExtractor
|
| 34 |
+
hop_length: 160 # 10ms hop
|
| 35 |
+
win_length: 320 # 20ms window
|
| 36 |
+
audio_ms_per_token: 40 # 40ms per audio token (subsampled 4x from 10ms frame)
|
| 37 |
+
augmentation:
|
| 38 |
+
enabled: false # Pure raw feature extraction (artificial noise disabled)
|
| 39 |
+
rir_prob: 0.0 # Reverb (RIR) disabled (0.0)
|
| 40 |
+
noise_prob: 0.0 # No artificial background noise
|
| 41 |
+
noise_snr_db_low: 15.0 # Low SNR for ambient lecture noise
|
| 42 |
+
noise_snr_db_high: 30.0
|
| 43 |
+
|
| 44 |
+
vocab:
|
| 45 |
+
target_vocab_size: 25000 # Lossless surface morpheme + syllable fallback (25,000 tokens)
|
| 46 |
+
blank_token: "<blank>" # Index 0 reserved for CTC blank
|
| 47 |
+
blank_id: 0
|
| 48 |
+
unk_token: "<unk>"
|
| 49 |
+
vocab_txt_path: "data/vocab/vocab_25k.txt"
|
| 50 |
+
vocab_json_path: "data/vocab/vocab_25k.json"
|
| 51 |
+
vector_bank_pt_path: "data/vocab/vector_bank_25k.pt"
|
| 52 |
+
vector_bank_bin_path: "data/vocab/vector_bank_25k.bin"
|
| 53 |
+
|
| 54 |
+
data:
|
| 55 |
+
cache_dir: "data/cache_features"
|
| 56 |
+
hf_dataset_name: "Bingsu/zeroth-korean"
|
| 57 |
+
max_audio_length_sec: 20.0
|
| 58 |
+
min_audio_length_sec: 0.5
|
| 59 |
+
|
| 60 |
+
train:
|
| 61 |
+
batch_size: 4 # Safe batch size for 16GB Mac mini (prevents MPS peak memory spikes)
|
| 62 |
+
grad_accum_steps: 4 # Effective batch size = 4 * 4 = 16 (prevents MPS OOM on 16GB Mac)
|
| 63 |
+
val_batch_size: 4
|
| 64 |
+
num_workers: 4 # 4 parallel worker processes for asynchronous feature prefetching
|
| 65 |
+
learning_rate: 2.0e-4
|
| 66 |
+
weight_decay: 1.0e-2
|
| 67 |
+
max_grad_norm: 1.0
|
| 68 |
+
num_epochs: 3 # Phase 1: 3 Epochs frozen anchor pre-alignment
|
| 69 |
+
scheduler: "cosine" # "cosine" or "onecycle"
|
| 70 |
+
warmup_ratio: 0.05
|
| 71 |
+
min_lr: 1.0e-6
|
| 72 |
+
eval_interval_epochs: 1
|
| 73 |
+
save_interval_epochs: 1
|
| 74 |
+
checkpoint_dir: "checkpoints"
|
| 75 |
+
|
| 76 |
+
export:
|
| 77 |
+
export_dir: "exports"
|
| 78 |
+
coreml:
|
| 79 |
+
model_name: "EmbeddingGemma2_KoreanSTT"
|
| 80 |
+
quantization: "int8" # "int8", "fp16", or "none"
|
| 81 |
+
onnx:
|
| 82 |
+
model_name: "embeddinggemma2_korean_stt"
|
| 83 |
+
opset_version: 18
|
| 84 |
+
quantization: "int8" # "int8" dynamic quantization
|
data/vocab/korean_lm.bin
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:26d946900210343069e364edaf15dabdd2f89c4d60ac719a10a572254f91d4ac
|
| 3 |
+
size 86738030
|
data/vocab/vector_bank.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ecf5bdeb93156b5e19b120066a2c14c2ffd487f3d8d59a83099c2695572603d8
|
| 3 |
+
size 12801093
|
data/vocab/vector_bank_25k.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ecf5bdeb93156b5e19b120066a2c14c2ffd487f3d8d59a83099c2695572603d8
|
| 3 |
+
size 12801093
|
data/vocab/vocab.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/vocab/vocab.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/vocab/vocab_25k.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/vocab/vocab_25k.txt
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.2.0
|
| 2 |
+
torchaudio>=2.2.0
|
| 3 |
+
transformers>=4.40.0
|
| 4 |
+
safetensors>=0.4.0
|
| 5 |
+
pyyaml>=6.0
|
| 6 |
+
numpy>=1.24.0
|
| 7 |
+
scipy>=1.11.0
|
| 8 |
+
tqdm>=4.66.0
|
| 9 |
+
psutil>=5.9.0
|
| 10 |
+
jiwer>=3.0.0
|
| 11 |
+
# kenlm: pip install https://github.com/kpu/kenlm/archive/master.zip
|
run_inference.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
run_inference.py: Production-ready Korean STT Inference Engine for Notesoop-STT-Ko-v1.
|
| 4 |
+
|
| 5 |
+
- Google EmbeddingGemma-2 Frozen 300M Audio Tower (MRL 256-dim feature extraction)
|
| 6 |
+
- 10-Layer Conformer Reader (21.5M params) on Apple Silicon MPS / CUDA / CPU
|
| 7 |
+
- 25,000 Morpheme Vocabulary with Cosine Similarity Matching
|
| 8 |
+
- Asynchronous KenLM 3-Gram Language Model Beam Search Rescoring
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
import difflib
|
| 15 |
+
import os
|
| 16 |
+
import re
|
| 17 |
+
import subprocess
|
| 18 |
+
import sys
|
| 19 |
+
import time
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from typing import Optional, List, Tuple
|
| 22 |
+
from concurrent.futures import ThreadPoolExecutor, Future
|
| 23 |
+
|
| 24 |
+
_ROOT = Path(__file__).resolve().parent
|
| 25 |
+
_SRC_DIR = _ROOT / "src"
|
| 26 |
+
if str(_SRC_DIR) not in sys.path:
|
| 27 |
+
sys.path.insert(0, str(_SRC_DIR))
|
| 28 |
+
if str(_ROOT) not in sys.path:
|
| 29 |
+
sys.path.insert(0, str(_ROOT))
|
| 30 |
+
|
| 31 |
+
import numpy as np
|
| 32 |
+
import torch
|
| 33 |
+
import yaml
|
| 34 |
+
|
| 35 |
+
from dataset import KoreanMorphemeTokenizer, normalize_text
|
| 36 |
+
from model import EmbeddingGemmaCTCReader
|
| 37 |
+
from step2_extract_features import GenuineAudioFeatureExtractor
|
| 38 |
+
from decoder import KenLMBeamSearchDecoder
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def load_audio(
|
| 42 |
+
file_path: str,
|
| 43 |
+
target_sr: int = 16000,
|
| 44 |
+
start_sec: float = 0.0,
|
| 45 |
+
duration_limit: float = 0.0
|
| 46 |
+
) -> Tuple[torch.Tensor, float]:
|
| 47 |
+
"""
|
| 48 |
+
Loads any audio container (.wav, .m4a, .mp3, .flac) into 16kHz float32 mono tensor via ffmpeg.
|
| 49 |
+
Uses ultra-fast seeking (-ss / -t) directly in FFmpeg to eliminate reading massive containers into memory.
|
| 50 |
+
"""
|
| 51 |
+
p = Path(file_path)
|
| 52 |
+
if not p.exists():
|
| 53 |
+
raise FileNotFoundError(f"Audio file not found: {file_path}")
|
| 54 |
+
|
| 55 |
+
cmd = ["ffmpeg", "-nostdin", "-threads", "0"]
|
| 56 |
+
if start_sec > 0:
|
| 57 |
+
cmd.extend(["-ss", str(start_sec)])
|
| 58 |
+
if duration_limit > 0:
|
| 59 |
+
cmd.extend(["-t", str(duration_limit)])
|
| 60 |
+
cmd.extend([
|
| 61 |
+
"-i", str(p),
|
| 62 |
+
"-f", "f32le", "-ac", "1", "-ar", str(target_sr), "-"
|
| 63 |
+
])
|
| 64 |
+
try:
|
| 65 |
+
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
| 66 |
+
out, _ = proc.communicate()
|
| 67 |
+
if proc.returncode != 0 or len(out) == 0:
|
| 68 |
+
raise RuntimeError("FFmpeg decode returned empty data")
|
| 69 |
+
arr = np.frombuffer(out, dtype=np.float32).copy()
|
| 70 |
+
except Exception:
|
| 71 |
+
# Fallback to soundfile if ffmpeg is not installed
|
| 72 |
+
import soundfile as sf
|
| 73 |
+
arr, sr = sf.read(str(p), dtype="float32")
|
| 74 |
+
if arr.ndim > 1:
|
| 75 |
+
arr = arr.mean(axis=1)
|
| 76 |
+
if start_sec > 0:
|
| 77 |
+
arr = arr[int(start_sec * sr):]
|
| 78 |
+
if duration_limit > 0:
|
| 79 |
+
arr = arr[:int(duration_limit * sr)]
|
| 80 |
+
if sr != target_sr:
|
| 81 |
+
import math
|
| 82 |
+
gcd = math.gcd(sr, target_sr)
|
| 83 |
+
up = target_sr // gcd
|
| 84 |
+
down = sr // gcd
|
| 85 |
+
import scipy.signal as sps
|
| 86 |
+
arr = sps.resample_poly(arr, up, down).astype(np.float32)
|
| 87 |
+
|
| 88 |
+
wav = torch.from_numpy(arr).unsqueeze(0)
|
| 89 |
+
duration_sec = wav.size(-1) / float(target_sr)
|
| 90 |
+
return wav, duration_sec
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def merge_chunk_transcripts(chunks: List[str]) -> str:
|
| 94 |
+
"""
|
| 95 |
+
Merges overlapping transcript segments by finding maximum character/word overlap.
|
| 96 |
+
Prevents stuttering/duplication across sliding audio window boundaries.
|
| 97 |
+
"""
|
| 98 |
+
if not chunks:
|
| 99 |
+
return ""
|
| 100 |
+
if len(chunks) == 1:
|
| 101 |
+
return chunks[0]
|
| 102 |
+
|
| 103 |
+
merged = chunks[0]
|
| 104 |
+
for nxt in chunks[1:]:
|
| 105 |
+
if not nxt:
|
| 106 |
+
continue
|
| 107 |
+
max_search = min(len(merged), len(nxt), 40)
|
| 108 |
+
found_overlap = 0
|
| 109 |
+
|
| 110 |
+
# Exact substring suffix-prefix match
|
| 111 |
+
for k in range(max_search, 2, -1):
|
| 112 |
+
if merged.endswith(nxt[:k]):
|
| 113 |
+
found_overlap = k
|
| 114 |
+
break
|
| 115 |
+
|
| 116 |
+
# Fuzzy sequence matcher
|
| 117 |
+
if found_overlap == 0 and len(merged) >= 10 and len(nxt) >= 10:
|
| 118 |
+
tail = merged[-30:]
|
| 119 |
+
head = nxt[:30]
|
| 120 |
+
matcher = difflib.SequenceMatcher(None, tail, head)
|
| 121 |
+
match = matcher.find_longest_match(0, len(tail), 0, len(head))
|
| 122 |
+
if match.size >= 4 and match.a + match.size == len(tail) and match.b == 0:
|
| 123 |
+
found_overlap = match.size
|
| 124 |
+
|
| 125 |
+
if found_overlap > 0:
|
| 126 |
+
merged = merged + nxt[found_overlap:]
|
| 127 |
+
else:
|
| 128 |
+
merged = merged + " " + nxt
|
| 129 |
+
|
| 130 |
+
return re.sub(r"\s+", " ", merged).strip()
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
class EmbeddingGemmaSTT:
|
| 134 |
+
"""
|
| 135 |
+
High-performance, standalone Korean STT Pipeline for Notesoop-STT-Ko-v1.
|
| 136 |
+
"""
|
| 137 |
+
def __init__(
|
| 138 |
+
self,
|
| 139 |
+
checkpoint_path: Optional[str] = None,
|
| 140 |
+
config_path: Optional[str] = None,
|
| 141 |
+
vocab_path: Optional[str] = None,
|
| 142 |
+
vector_bank_path: Optional[str] = None,
|
| 143 |
+
lm_path: Optional[str] = None,
|
| 144 |
+
device: str = "auto",
|
| 145 |
+
use_kenlm: bool = True,
|
| 146 |
+
lm_alpha: float = 0.12,
|
| 147 |
+
lm_beta: float = 0.40,
|
| 148 |
+
**kwargs # Ignore legacy kwargs like engine
|
| 149 |
+
):
|
| 150 |
+
self.root = _ROOT
|
| 151 |
+
self.lm_alpha = lm_alpha
|
| 152 |
+
self.lm_beta = lm_beta
|
| 153 |
+
|
| 154 |
+
# 0. Determine Device
|
| 155 |
+
if device == "auto":
|
| 156 |
+
dev_str = "mps" if torch.backends.mps.is_available() else ("cuda" if torch.cuda.is_available() else "cpu")
|
| 157 |
+
else:
|
| 158 |
+
dev_str = "mps" if (device == "metal" and torch.backends.mps.is_available()) else device
|
| 159 |
+
|
| 160 |
+
self.device = torch.device(dev_str)
|
| 161 |
+
|
| 162 |
+
# 1. Resolve paths & Tokenizer
|
| 163 |
+
cfg_p = Path(config_path) if config_path else self.root / "configs/config.yaml"
|
| 164 |
+
with open(cfg_p, "r", encoding="utf-8") as f:
|
| 165 |
+
self.cfg = yaml.safe_load(f)
|
| 166 |
+
|
| 167 |
+
v_p = Path(vocab_path) if vocab_path else self.root / "data/vocab/vocab_25k.json"
|
| 168 |
+
if not v_p.exists():
|
| 169 |
+
v_p = self.root / "data/vocab/vocab.json"
|
| 170 |
+
self.tokenizer = KoreanMorphemeTokenizer(str(v_p))
|
| 171 |
+
|
| 172 |
+
vb_p = Path(vector_bank_path) if vector_bank_path else self.root / "data/vocab/vector_bank_25k.pt"
|
| 173 |
+
if not vb_p.exists():
|
| 174 |
+
vb_p = self.root / "data/vocab/vector_bank.pt"
|
| 175 |
+
self.vector_bank = torch.load(vb_p, map_location="cpu", weights_only=True)
|
| 176 |
+
|
| 177 |
+
# 2. Initialize Conformer Reader
|
| 178 |
+
if checkpoint_path:
|
| 179 |
+
ckpt_p = Path(checkpoint_path)
|
| 180 |
+
else:
|
| 181 |
+
p_inf = self.root / "checkpoints/best_reader_inference.pt"
|
| 182 |
+
p_full = self.root / "checkpoints/best_reader.pt"
|
| 183 |
+
ckpt_p = p_inf if p_inf.exists() else p_full
|
| 184 |
+
|
| 185 |
+
if not ckpt_p.exists():
|
| 186 |
+
raise FileNotFoundError(f"Reader checkpoint not found at: {ckpt_p}")
|
| 187 |
+
|
| 188 |
+
ckpt = torch.load(ckpt_p, map_location="cpu", weights_only=False)
|
| 189 |
+
state_dict = ckpt.get("model_state_dict", ckpt)
|
| 190 |
+
|
| 191 |
+
vb_model = state_dict.get("ctc_head.vector_bank", self.vector_bank)
|
| 192 |
+
d_model = state_dict["reader.input_proj.weight"].shape[0] if "reader.input_proj.weight" in state_dict else 384
|
| 193 |
+
input_dim = state_dict["reader.input_proj.weight"].shape[1] if "reader.input_proj.weight" in state_dict else 256
|
| 194 |
+
detected_layers = len([k for k in state_dict.keys() if k.startswith("reader.layers.") and k.endswith(".final_norm.weight")])
|
| 195 |
+
num_layers = detected_layers if detected_layers > 0 else 10
|
| 196 |
+
dim_ffn = state_dict["reader.layers.0.ffn1.0.weight"].shape[0] if "reader.layers.0.ffn1.0.weight" in state_dict else 1536
|
| 197 |
+
nhead = d_model // 64
|
| 198 |
+
output_dim = state_dict["reader.output_proj.0.weight"].shape[0] if "reader.output_proj.0.weight" in state_dict else 256
|
| 199 |
+
|
| 200 |
+
self.reader = EmbeddingGemmaCTCReader(
|
| 201 |
+
input_dim=input_dim,
|
| 202 |
+
output_dim=output_dim,
|
| 203 |
+
d_model=d_model,
|
| 204 |
+
nhead=nhead,
|
| 205 |
+
num_layers=num_layers,
|
| 206 |
+
dim_feedforward=dim_ffn,
|
| 207 |
+
dropout=0.0,
|
| 208 |
+
init_temperature=0.05,
|
| 209 |
+
vector_bank=vb_model,
|
| 210 |
+
use_spec_augment=False,
|
| 211 |
+
trainable_vector_bank=False
|
| 212 |
+
).to(self.device)
|
| 213 |
+
self.reader.load_state_dict(state_dict, strict=False)
|
| 214 |
+
self.reader.eval()
|
| 215 |
+
|
| 216 |
+
# 3. Audio Feature Extractor (Google EmbeddingGemma-2 300M Audio Tower)
|
| 217 |
+
model_name = self.cfg["model"].get("name", "google/embeddinggemma-2")
|
| 218 |
+
self.extractor = GenuineAudioFeatureExtractor(device=self.device, model_name=model_name)
|
| 219 |
+
|
| 220 |
+
# 4. Optional KenLM Decoder
|
| 221 |
+
self.decoder_kenlm = None
|
| 222 |
+
if use_kenlm:
|
| 223 |
+
lm_file = Path(lm_path) if lm_path else self.root / "data/vocab/korean_lm.bin"
|
| 224 |
+
if lm_file.exists():
|
| 225 |
+
self.decoder_kenlm = KenLMBeamSearchDecoder(
|
| 226 |
+
tokenizer=self.tokenizer,
|
| 227 |
+
lm_path=str(lm_file),
|
| 228 |
+
beam_size=8,
|
| 229 |
+
alpha=lm_alpha,
|
| 230 |
+
beta=lm_beta
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
@torch.no_grad()
|
| 234 |
+
def transcribe(
|
| 235 |
+
self,
|
| 236 |
+
audio_path: str,
|
| 237 |
+
chunk_sec: float = 16.0,
|
| 238 |
+
overlap_sec: float = 1.0,
|
| 239 |
+
start_sec: float = 0.0,
|
| 240 |
+
duration_limit: float = 0.0,
|
| 241 |
+
batch_size: int = 4
|
| 242 |
+
) -> Tuple[str, float]:
|
| 243 |
+
"""
|
| 244 |
+
Transcribes audio file of arbitrary length into Korean text.
|
| 245 |
+
Optimized with Batched GPU Forward Passes and Asynchronous KenLM Rescoring:
|
| 246 |
+
- CPU Mel Preprocessing
|
| 247 |
+
- GPU 300M Audio Tower Forward (Batched)
|
| 248 |
+
- GPU Conformer Reader + Top-32 Reduction (Batched)
|
| 249 |
+
- Asynchronous KenLM 3-Gram Beam Search Rescoring
|
| 250 |
+
Returns (transcript_text, rtf_speed).
|
| 251 |
+
"""
|
| 252 |
+
wav, eval_dur = load_audio(
|
| 253 |
+
audio_path,
|
| 254 |
+
target_sr=16000,
|
| 255 |
+
start_sec=start_sec,
|
| 256 |
+
duration_limit=duration_limit
|
| 257 |
+
)
|
| 258 |
+
sr = 16000
|
| 259 |
+
step_sec = max(chunk_sec - overlap_sec, 0.5) if overlap_sec > 0 else chunk_sec
|
| 260 |
+
step_samples = int(step_sec * sr)
|
| 261 |
+
chunk_samples = int(chunk_sec * sr)
|
| 262 |
+
total_samples = wav.size(-1)
|
| 263 |
+
|
| 264 |
+
chunk_ranges: List[Tuple[int, int]] = []
|
| 265 |
+
c_start = 0
|
| 266 |
+
while c_start < total_samples:
|
| 267 |
+
c_end = min(c_start + chunk_samples, total_samples)
|
| 268 |
+
if (c_end - c_start) / sr >= 0.5:
|
| 269 |
+
chunk_ranges.append((c_start, c_end))
|
| 270 |
+
if c_end == total_samples:
|
| 271 |
+
break
|
| 272 |
+
c_start += step_samples
|
| 273 |
+
|
| 274 |
+
if not chunk_ranges:
|
| 275 |
+
return "", 0.0
|
| 276 |
+
|
| 277 |
+
t0 = time.time()
|
| 278 |
+
|
| 279 |
+
# 1. Preprocess Mel-spectrogram on CPU (pad last chunk to chunk_samples for uniform shapes)
|
| 280 |
+
preprocessed_chunks = []
|
| 281 |
+
for cs, ce in chunk_ranges:
|
| 282 |
+
c_wav = wav[:, cs:ce]
|
| 283 |
+
if c_wav.size(-1) < chunk_samples:
|
| 284 |
+
c_wav = torch.nn.functional.pad(c_wav, (0, chunk_samples - c_wav.size(-1)))
|
| 285 |
+
preprocessed_chunks.append(self.extractor.preprocess_audio_cpu(c_wav))
|
| 286 |
+
|
| 287 |
+
# 2. Pipelined Batched Inference
|
| 288 |
+
chunk_texts: List[str] = []
|
| 289 |
+
kenlm_futures: List[Future] = []
|
| 290 |
+
use_kenlm_async = (self.decoder_kenlm is not None)
|
| 291 |
+
|
| 292 |
+
with ThreadPoolExecutor(max_workers=2) as executor:
|
| 293 |
+
for b_idx in range(0, len(preprocessed_chunks), batch_size):
|
| 294 |
+
b_items = preprocessed_chunks[b_idx : b_idx + batch_size]
|
| 295 |
+
|
| 296 |
+
# Step A: Extract Audio Tower Features (GPU)
|
| 297 |
+
if len(b_items) > 1 and len(set(it[3] for it in b_items)) == 1:
|
| 298 |
+
feats = self.extractor.extract_batch_same_bucket(b_items)
|
| 299 |
+
else:
|
| 300 |
+
feats = [self.extractor.extract_from_preprocessed(*it) for it in b_items]
|
| 301 |
+
|
| 302 |
+
# Step B: Conformer Reader Forward (Batched GPU)
|
| 303 |
+
if len(feats) > 1 and len(set(f.size(0) for f in feats)) == 1:
|
| 304 |
+
feats_batch = torch.stack(feats, dim=0).to(device=self.device, dtype=torch.float32)
|
| 305 |
+
logits_batch = self.reader(feats_batch)
|
| 306 |
+
else:
|
| 307 |
+
logits_batch = [
|
| 308 |
+
self.reader(f.unsqueeze(0).to(device=self.device, dtype=torch.float32)).squeeze(0)
|
| 309 |
+
for f in feats
|
| 310 |
+
]
|
| 311 |
+
|
| 312 |
+
# Step C: Decoding (KenLM Beam Search or Greedy CTC)
|
| 313 |
+
for b in range(len(feats)):
|
| 314 |
+
logits = logits_batch[b]
|
| 315 |
+
if use_kenlm_async:
|
| 316 |
+
topk_lprobs, topk_ids = torch.topk(logits, k=32, dim=-1)
|
| 317 |
+
blank_lprobs = logits[:, 0]
|
| 318 |
+
fut = executor.submit(
|
| 319 |
+
self.decoder_kenlm.decode_topk,
|
| 320 |
+
topk_ids.detach().cpu().to(torch.int32).numpy(),
|
| 321 |
+
topk_lprobs.detach().cpu().float().numpy(),
|
| 322 |
+
blank_lprobs.detach().cpu().float().numpy(),
|
| 323 |
+
alpha=self.lm_alpha,
|
| 324 |
+
beta=self.lm_beta
|
| 325 |
+
)
|
| 326 |
+
kenlm_futures.append(fut)
|
| 327 |
+
else:
|
| 328 |
+
pred_ids = torch.argmax(logits, dim=-1).cpu().tolist()
|
| 329 |
+
txt = self.tokenizer.decode(pred_ids, remove_blank=True, collapse_repeats=True)
|
| 330 |
+
chunk_texts.append(txt)
|
| 331 |
+
|
| 332 |
+
if use_kenlm_async:
|
| 333 |
+
chunk_texts = [fut.result() for fut in kenlm_futures]
|
| 334 |
+
|
| 335 |
+
t_elapsed = time.time() - t0
|
| 336 |
+
rtf = t_elapsed / max(eval_dur, 0.001)
|
| 337 |
+
|
| 338 |
+
valid_texts = [t.strip() for t in chunk_texts if t.strip()]
|
| 339 |
+
if overlap_sec > 0:
|
| 340 |
+
full_text = merge_chunk_transcripts(valid_texts)
|
| 341 |
+
else:
|
| 342 |
+
full_text = " ".join(valid_texts)
|
| 343 |
+
|
| 344 |
+
return full_text, rtf
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def main():
|
| 348 |
+
parser = argparse.ArgumentParser(description="Notesoop-STT-Ko-v1 Standalone Inference")
|
| 349 |
+
parser.add_argument("--audio", type=str, default="sample_audio.wav", help="Path to audio file (.wav, .m4a, .mp3, etc.)")
|
| 350 |
+
parser.add_argument("--checkpoint", type=str, default=None, help="Path to checkpoint (.pt)")
|
| 351 |
+
parser.add_argument("--device", type=str, default="auto", choices=["auto", "mps", "cuda", "cpu"], help="Inference device")
|
| 352 |
+
parser.add_argument("--kenlm", action="store_true", default=True, help="Enable KenLM 3-gram language model rescoring")
|
| 353 |
+
parser.add_argument("--no_kenlm", dest="kenlm", action="store_false", help="Disable KenLM (Greedy decoding only)")
|
| 354 |
+
parser.add_argument("--duration", type=float, default=0.0, help="Duration limit in seconds (0 for full audio)")
|
| 355 |
+
parser.add_argument("--chunk_sec", type=float, default=16.0, help="Chunk length in seconds")
|
| 356 |
+
parser.add_argument("--batch_size", type=int, default=4, help="Batch size for parallel processing (default: 4)")
|
| 357 |
+
parser.add_argument("--output", type=str, default=None, help="Optional output .txt file path")
|
| 358 |
+
args = parser.parse_args()
|
| 359 |
+
|
| 360 |
+
print("\n" + "=" * 70)
|
| 361 |
+
print("🌲 Notesoop-STT-Ko-v1: Korean Speech Recognition Engine")
|
| 362 |
+
print("=" * 70)
|
| 363 |
+
print(f" • Audio File : {args.audio}")
|
| 364 |
+
print(f" • Device : {args.device.upper()}")
|
| 365 |
+
print(f" • Batch Size : {args.batch_size}")
|
| 366 |
+
print(f" • KenLM LM : {'Enabled (8-beam rescoring)' if args.kenlm else 'Disabled (Greedy)'}")
|
| 367 |
+
print("-" * 70)
|
| 368 |
+
|
| 369 |
+
stt = EmbeddingGemmaSTT(
|
| 370 |
+
checkpoint_path=args.checkpoint,
|
| 371 |
+
device=args.device,
|
| 372 |
+
use_kenlm=args.kenlm
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
print("\n🚀 Transcribing...")
|
| 376 |
+
text, rtf = stt.transcribe(
|
| 377 |
+
args.audio,
|
| 378 |
+
chunk_sec=args.chunk_sec,
|
| 379 |
+
duration_limit=args.duration,
|
| 380 |
+
batch_size=args.batch_size
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
+
print("\n" + "=" * 70)
|
| 384 |
+
print("📝 [Transcription Result]:")
|
| 385 |
+
print("=" * 70)
|
| 386 |
+
print(text)
|
| 387 |
+
print("-" * 70)
|
| 388 |
+
print(f"⏱️ Speed / RTF: {rtf:.4f} ({1.0 / max(rtf, 1e-4):.1f}x Real-time on {stt.device.type.upper()})")
|
| 389 |
+
print("=" * 70 + "\n")
|
| 390 |
+
|
| 391 |
+
if args.output:
|
| 392 |
+
out_p = Path(args.output)
|
| 393 |
+
out_p.parent.mkdir(parents=True, exist_ok=True)
|
| 394 |
+
out_p.write_text(text, encoding="utf-8")
|
| 395 |
+
print(f"💾 Saved transcription to: {out_p}\n")
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
if __name__ == "__main__":
|
| 399 |
+
main()
|
sample_audio.wav
ADDED
|
Binary file (96 kB). View file
|
|
|
src/dataset.py
ADDED
|
@@ -0,0 +1,774 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
src/dataset.py
|
| 3 |
+
|
| 4 |
+
Dataset Loader and Dynamic Padding Collate Function for Pre-cached Audio Features.
|
| 5 |
+
Handles cached .pt representations, token ID targets, attention masks, and CTC sequence lengths.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import glob
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import re
|
| 14 |
+
import unicodedata
|
| 15 |
+
import zipfile
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import List, Dict, Any, Tuple, Optional
|
| 18 |
+
|
| 19 |
+
import torch
|
| 20 |
+
from torch.utils.data import Dataset
|
| 21 |
+
from tqdm import tqdm
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ==============================================================================
|
| 25 |
+
# 1. Korean Morpheme Tokenizer & Normalization
|
| 26 |
+
# ==============================================================================
|
| 27 |
+
|
| 28 |
+
# SentencePiece word prefix marker (U+2581 ' ')
|
| 29 |
+
WORD_PREFIX = " "
|
| 30 |
+
|
| 31 |
+
# Korean compatibility consonant (U+3131~U+314E) to standard conjoining jongseong (U+11A8~U+11C2) mapping
|
| 32 |
+
COMPAT_TO_JONGSEONG: Dict[str, str] = {
|
| 33 |
+
"ㄱ": "\u11a8", "ㄲ": "\u11a9", "ㄳ": "\u11aa", "ㄴ": "\u11ab", "ㄵ": "\u11ac",
|
| 34 |
+
"ㄶ": "\u11ad", "ㄷ": "\u11ae", "ㄹ": "\u11af", "ㄺ": "\u11b0", "ㄻ": "\u11b1",
|
| 35 |
+
"ㄼ": "\u11b2", "ㄽ": "\u11b3", "ㄾ": "\u11b4", "ㄿ": "\u11b5", "ㅀ": "\u11b6",
|
| 36 |
+
"ㅁ": "\u11b7", "ㅂ": "\u11b8", "ㅄ": "\u11b9", "ㅅ": "\u11ba", "ㅆ": "\u11bb",
|
| 37 |
+
"ㅇ": "\u11bc", "ㅈ": "\u11bd", "ㅊ": "\u11be", "ㅋ": "\u11bf", "ㅌ": "\u11c0",
|
| 38 |
+
"ㅍ": "\u11c1", "ㅎ": "\u11c2"
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
_PUNCT_RE = re.compile(r"[?!.,\"~:;()\[\]{}<>@#$%^&*_+=\/\\|`·…“”‘’]")
|
| 42 |
+
_SPACE_RE = re.compile(r"\s+")
|
| 43 |
+
|
| 44 |
+
def normalize_text(text: str) -> str:
|
| 45 |
+
"""
|
| 46 |
+
Normalizes spoken Korean text by stripping punctuation marks (?, ., ,, !, etc.),
|
| 47 |
+
preserving Korean characters, alphanumeric characters, and whitespace.
|
| 48 |
+
"""
|
| 49 |
+
text = _PUNCT_RE.sub(" ", text)
|
| 50 |
+
return _SPACE_RE.sub(" ", text).strip()
|
| 51 |
+
|
| 52 |
+
import sqlite3
|
| 53 |
+
import threading
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def canonicalize_aihub_key(key: str) -> str:
|
| 57 |
+
"""Normalizes any AI Hub path or key (e.g. 'art/desi/C00890/U00004.wav') to 'C00890_U00004'."""
|
| 58 |
+
k = key.replace("\\", "/").strip("/")
|
| 59 |
+
if k.endswith((".wav", ".pcm", ".flac", ".json")):
|
| 60 |
+
k = k.rsplit(".", 1)[0]
|
| 61 |
+
if "/" in k:
|
| 62 |
+
parts = k.split("/")
|
| 63 |
+
if len(parts) >= 2:
|
| 64 |
+
return f"{parts[-2]}_{parts[-1]}"
|
| 65 |
+
return parts[-1]
|
| 66 |
+
return k
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class AIHubLabelIndexDB:
|
| 70 |
+
"""
|
| 71 |
+
Zero-RAM SQLite-backed dictionary interface for AI Hub 1.71M lecture transcriptions.
|
| 72 |
+
Replaces the 681MB in-memory JSON dict (~2.5-4.5GB RAM) with an on-disk B-Tree index (<5MB RAM).
|
| 73 |
+
Thread-safe across main thread and PrefetchedStream worker threads.
|
| 74 |
+
"""
|
| 75 |
+
def __init__(self, db_path: Path):
|
| 76 |
+
self.db_path = Path(db_path)
|
| 77 |
+
self._local = threading.local()
|
| 78 |
+
self._count: Optional[int] = None
|
| 79 |
+
|
| 80 |
+
def _get_conn(self) -> sqlite3.Connection:
|
| 81 |
+
conn = getattr(self._local, "conn", None)
|
| 82 |
+
if conn is None:
|
| 83 |
+
conn = sqlite3.connect(f"file:{self.db_path}?mode=ro", uri=True, check_same_thread=False)
|
| 84 |
+
conn.execute("PRAGMA cache_size = -4000") # Cap SQLite page cache to ~4MB
|
| 85 |
+
conn.execute("PRAGMA temp_store = MEMORY")
|
| 86 |
+
self._local.conn = conn
|
| 87 |
+
return conn
|
| 88 |
+
|
| 89 |
+
def get(self, key: str, default: Optional[str] = None) -> Optional[str]:
|
| 90 |
+
if not key:
|
| 91 |
+
return default
|
| 92 |
+
ckey = canonicalize_aihub_key(key)
|
| 93 |
+
cur = self._get_conn().execute("SELECT text FROM labels WHERE key = ?", (ckey,))
|
| 94 |
+
row = cur.fetchone()
|
| 95 |
+
return row[0] if row is not None else default
|
| 96 |
+
|
| 97 |
+
def __getitem__(self, key: str) -> str:
|
| 98 |
+
val = self.get(key)
|
| 99 |
+
if val is None:
|
| 100 |
+
raise KeyError(key)
|
| 101 |
+
return val
|
| 102 |
+
|
| 103 |
+
def __contains__(self, key: str) -> bool:
|
| 104 |
+
return self.get(key) is not None
|
| 105 |
+
|
| 106 |
+
def __len__(self) -> int:
|
| 107 |
+
if self._count is None:
|
| 108 |
+
cur = self._get_conn().execute("SELECT COUNT(*) FROM labels")
|
| 109 |
+
row = cur.fetchone()
|
| 110 |
+
self._count = int(row[0]) if row else 0
|
| 111 |
+
return self._count
|
| 112 |
+
|
| 113 |
+
def values(self):
|
| 114 |
+
cur = self._get_conn().execute("SELECT text FROM labels")
|
| 115 |
+
for (txt,) in cur:
|
| 116 |
+
yield txt
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def load_or_build_aihub_label_index(
|
| 120 |
+
tl_zip_path: str = "/Users/yeongchan/Downloads/137.한국어 대학 강의 데이터/01-1.정식개방데이터/Training/02.라벨링데이터/TL.zip",
|
| 121 |
+
index_cache_path: str = "data/vocab/aihub_label_index.db",
|
| 122 |
+
max_scan_entries: Optional[int] = None
|
| 123 |
+
) -> Any:
|
| 124 |
+
"""
|
| 125 |
+
Scans TL.zip directly in memory without disk extraction and stores canonical keys
|
| 126 |
+
in a lightweight SQLite database (data/vocab/aihub_label_index.db) consuming <5MB RAM.
|
| 127 |
+
"""
|
| 128 |
+
project_root = Path(__file__).resolve().parent.parent
|
| 129 |
+
cache_path = Path(index_cache_path)
|
| 130 |
+
if not cache_path.is_absolute():
|
| 131 |
+
cache_path = project_root / cache_path
|
| 132 |
+
db_path = cache_path.with_suffix(".db")
|
| 133 |
+
legacy_json_path = cache_path.with_suffix(".json")
|
| 134 |
+
|
| 135 |
+
if db_path.exists() and db_path.stat().st_size > 1024 * 1024:
|
| 136 |
+
db_index = AIHubLabelIndexDB(db_path)
|
| 137 |
+
print(f"[AIHub Index] Opened low-memory SQLite label index ({len(db_index):,} utterances, <5MB RAM) at {db_path.name}")
|
| 138 |
+
return db_index
|
| 139 |
+
|
| 140 |
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
| 141 |
+
tmp_db_path = db_path.with_suffix(".db.tmp")
|
| 142 |
+
if tmp_db_path.exists():
|
| 143 |
+
tmp_db_path.unlink()
|
| 144 |
+
|
| 145 |
+
conn = sqlite3.connect(str(tmp_db_path))
|
| 146 |
+
conn.execute("PRAGMA journal_mode = OFF")
|
| 147 |
+
conn.execute("PRAGMA synchronous = OFF")
|
| 148 |
+
conn.execute("CREATE TABLE labels (key TEXT PRIMARY KEY, text TEXT) WITHOUT ROWID")
|
| 149 |
+
|
| 150 |
+
tl_zip = Path(tl_zip_path)
|
| 151 |
+
if not tl_zip.exists():
|
| 152 |
+
conn.close()
|
| 153 |
+
raise FileNotFoundError(f"AI Hub TL.zip not found at {tl_zip}")
|
| 154 |
+
|
| 155 |
+
print(f"[AIHub Index] Building low-memory SQLite index from {tl_zip.name} without disk extraction...")
|
| 156 |
+
batch_rows: List[Tuple[str, str]] = []
|
| 157 |
+
inserted = 0
|
| 158 |
+
|
| 159 |
+
with zipfile.ZipFile(tl_zip, "r") as zf:
|
| 160 |
+
infolist = zf.infolist()
|
| 161 |
+
valid_infos = [
|
| 162 |
+
info for info in infolist
|
| 163 |
+
if info.filename.endswith(".json")
|
| 164 |
+
and not info.filename.startswith("__MACOSX")
|
| 165 |
+
and ".irx" not in info.filename
|
| 166 |
+
and ".part" not in info.filename
|
| 167 |
+
and ".DS_Store" not in info.filename
|
| 168 |
+
]
|
| 169 |
+
if max_scan_entries and len(valid_infos) > max_scan_entries:
|
| 170 |
+
valid_infos = valid_infos[:max_scan_entries]
|
| 171 |
+
|
| 172 |
+
for info in tqdm(valid_infos, desc="Indexing TL.zip -> SQLite"):
|
| 173 |
+
try:
|
| 174 |
+
raw_bytes = zf.read(info)
|
| 175 |
+
jdata = json.loads(raw_bytes.decode("utf-8"))
|
| 176 |
+
raw_txt = jdata.get("06_transcription", {}).get("1_text", "").strip()
|
| 177 |
+
if not raw_txt:
|
| 178 |
+
continue
|
| 179 |
+
|
| 180 |
+
clean_txt = re.sub(r"\([^)]*\)/\(([^)]*)\)", r"\1", raw_txt)
|
| 181 |
+
clean_txt = normalize_text(clean_txt)
|
| 182 |
+
if not clean_txt:
|
| 183 |
+
continue
|
| 184 |
+
|
| 185 |
+
src_path = jdata.get("01_dataset", {}).get("3_src_path", "") or info.filename
|
| 186 |
+
ckey = canonicalize_aihub_key(src_path)
|
| 187 |
+
batch_rows.append((ckey, clean_txt))
|
| 188 |
+
|
| 189 |
+
if len(batch_rows) >= 20000:
|
| 190 |
+
conn.executemany("INSERT OR REPLACE INTO labels (key, text) VALUES (?, ?)", batch_rows)
|
| 191 |
+
conn.commit()
|
| 192 |
+
inserted += len(batch_rows)
|
| 193 |
+
batch_rows.clear()
|
| 194 |
+
except Exception:
|
| 195 |
+
continue
|
| 196 |
+
|
| 197 |
+
if batch_rows:
|
| 198 |
+
conn.executemany("INSERT OR REPLACE INTO labels (key, text) VALUES (?, ?)", batch_rows)
|
| 199 |
+
conn.commit()
|
| 200 |
+
inserted += len(batch_rows)
|
| 201 |
+
batch_rows.clear()
|
| 202 |
+
|
| 203 |
+
conn.close()
|
| 204 |
+
tmp_db_path.replace(db_path)
|
| 205 |
+
|
| 206 |
+
if legacy_json_path.exists():
|
| 207 |
+
try:
|
| 208 |
+
legacy_json_path.unlink()
|
| 209 |
+
print(f"[AIHub Index] Removed legacy 681MB JSON cache ({legacy_json_path.name}) to free disk & RAM.")
|
| 210 |
+
except Exception:
|
| 211 |
+
pass
|
| 212 |
+
|
| 213 |
+
db_index = AIHubLabelIndexDB(db_path)
|
| 214 |
+
print(f"[AIHub Index] SQLite index ready at {db_path.name} ({len(db_index):,} unique utterances).")
|
| 215 |
+
return db_index
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def is_consonant_leading(token: str) -> bool:
|
| 219 |
+
"""
|
| 220 |
+
Checks if a token starts with a standalone consonant / jamo (e.g. ᆫ, ᆯ, ᆷ, ᆸ, ᆺ, or compatibility ㄱ-ㅎ).
|
| 221 |
+
"""
|
| 222 |
+
if not token:
|
| 223 |
+
return False
|
| 224 |
+
c = token[0]
|
| 225 |
+
# Compatibility consonants: U+3131 ~ U+314E
|
| 226 |
+
if "\u3131" <= c <= "\u314e":
|
| 227 |
+
return True
|
| 228 |
+
# Conjoining jamo: choseong (U+1100~U+115F) or jongseong (U+11A8~U+11FF)
|
| 229 |
+
if "\u1100" <= c <= "\u11ff":
|
| 230 |
+
return True
|
| 231 |
+
return False
|
| 232 |
+
|
| 233 |
+
def merge_jamo_to_syllable(prev: str, curr: str) -> str:
|
| 234 |
+
"""
|
| 235 |
+
Composes a preceding morpheme with a leading standalone jamo into a complete syllable:
|
| 236 |
+
e.g. '쌓이' + 'ᆫ' -> '쌓인'
|
| 237 |
+
'이' + 'ᆸ니다' -> '입니다'
|
| 238 |
+
'만들' + '어' + '지' + 'ᆫ' -> '만들', '어', '진'
|
| 239 |
+
'참여' + '하' + 'ᆯ' -> '참여', '할'
|
| 240 |
+
'만들' + 'ᆫ다' -> '만든다' (ㄹ-탈락 규칙)
|
| 241 |
+
'살' + 'ᆸ니다' -> '삽니다' (ㄹ-탈락 규칙)
|
| 242 |
+
'그렇' + 'ᆫ가' -> '그런가' (ㅎ-탈락 규칙)
|
| 243 |
+
Uses unicodedata NFD decomposition and NFC re-composition.
|
| 244 |
+
"""
|
| 245 |
+
if not prev:
|
| 246 |
+
return curr
|
| 247 |
+
first_char = curr[0]
|
| 248 |
+
first_mapped = COMPAT_TO_JONGSEONG.get(first_char, first_char)
|
| 249 |
+
curr_mapped = first_mapped + curr[1:]
|
| 250 |
+
|
| 251 |
+
prev_nfd = list(unicodedata.normalize("NFD", prev))
|
| 252 |
+
curr_nfd = list(unicodedata.normalize("NFD", curr_mapped))
|
| 253 |
+
|
| 254 |
+
# If prev ends in a jongseong and curr starts with a jongseong:
|
| 255 |
+
# in Korean phonological rules (e.g. ㄹ-탈락, ㅎ-탈락), the stem jongseong is dropped
|
| 256 |
+
if prev_nfd and ("\u11a8" <= prev_nfd[-1] <= "\u11c2"):
|
| 257 |
+
if curr_nfd and ("\u11a8" <= curr_nfd[0] <= "\u11c2"):
|
| 258 |
+
test_merged = unicodedata.normalize("NFC", "".join(prev_nfd + curr_nfd))
|
| 259 |
+
if any(("\u1100" <= c <= "\u11ff" or "\u3131" <= c <= "\u314e") for c in test_merged):
|
| 260 |
+
prev_nfd = prev_nfd[:-1]
|
| 261 |
+
|
| 262 |
+
merged = unicodedata.normalize("NFC", "".join(prev_nfd + curr_nfd))
|
| 263 |
+
return merged
|
| 264 |
+
|
| 265 |
+
def strip_word_prefix(tok: str) -> Tuple[bool, str]:
|
| 266 |
+
"""
|
| 267 |
+
Checks if a token has a word-boundary prefix (' ', ' ▁', or space) and strips it.
|
| 268 |
+
Returns (is_word_start, clean_token).
|
| 269 |
+
"""
|
| 270 |
+
if tok.startswith(WORD_PREFIX) or tok.startswith(" ") or tok.startswith(" ▁") or tok.startswith(" "):
|
| 271 |
+
clean = tok.lstrip(" \u2581▁")
|
| 272 |
+
return True, clean
|
| 273 |
+
return False, tok
|
| 274 |
+
|
| 275 |
+
def tokenize_korean_syllables(text: str, word_prefix: str = WORD_PREFIX) -> List[str]:
|
| 276 |
+
clean_text = normalize_text(text)
|
| 277 |
+
if not clean_text:
|
| 278 |
+
return []
|
| 279 |
+
tokens: List[str] = []
|
| 280 |
+
for word in clean_text.split(" "):
|
| 281 |
+
if not word:
|
| 282 |
+
continue
|
| 283 |
+
for idx, ch in enumerate(word):
|
| 284 |
+
tokens.append((word_prefix + ch) if idx == 0 else ch)
|
| 285 |
+
return tokens
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def tokenize_korean_hybrid(
|
| 289 |
+
text: str,
|
| 290 |
+
token_to_id: Dict[str, int],
|
| 291 |
+
word_prefix: str = WORD_PREFIX,
|
| 292 |
+
max_tok_len: int = 16
|
| 293 |
+
) -> List[str]:
|
| 294 |
+
"""
|
| 295 |
+
Greedy Longest-Match (Maximal Matching) Surface Morpheme Tokenizer with Syllable Fallback.
|
| 296 |
+
Matches the longest registered surface morpheme/word in vocabulary first.
|
| 297 |
+
Falls back to single-character KS X 1001 syllables when a word/morpheme is unregistered,
|
| 298 |
+
guaranteeing 0.00% OOV with maximum semantic chunking efficiency.
|
| 299 |
+
"""
|
| 300 |
+
clean_text = normalize_text(text)
|
| 301 |
+
if not clean_text:
|
| 302 |
+
return []
|
| 303 |
+
tokens: List[str] = []
|
| 304 |
+
for word in clean_text.split(" "):
|
| 305 |
+
if not word:
|
| 306 |
+
continue
|
| 307 |
+
w_len = len(word)
|
| 308 |
+
pos = 0
|
| 309 |
+
first_in_word = True
|
| 310 |
+
while pos < w_len:
|
| 311 |
+
matched = False
|
| 312 |
+
max_end = w_len if w_len - pos <= max_tok_len else pos + max_tok_len
|
| 313 |
+
for end in range(max_end, pos, -1):
|
| 314 |
+
sub = word[pos:end]
|
| 315 |
+
candidate = (word_prefix + sub) if first_in_word else sub
|
| 316 |
+
if candidate in token_to_id:
|
| 317 |
+
tokens.append(candidate)
|
| 318 |
+
pos = end
|
| 319 |
+
first_in_word = False
|
| 320 |
+
matched = True
|
| 321 |
+
break
|
| 322 |
+
if not matched:
|
| 323 |
+
sub = word[pos:pos + 1]
|
| 324 |
+
candidate = (word_prefix + sub) if first_in_word else sub
|
| 325 |
+
tokens.append(candidate)
|
| 326 |
+
pos += 1
|
| 327 |
+
first_in_word = False
|
| 328 |
+
return tokens
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
# Backward compatibility alias
|
| 332 |
+
tokenize_korean_morphemes = tokenize_korean_hybrid
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
class KoreanMorphemeTokenizer:
|
| 336 |
+
"""
|
| 337 |
+
Surface Morpheme + Syllable Fallback Hybrid Tokenizer using generated vocab.json with word boundary prefix.
|
| 338 |
+
Utilizes O(L) Greedy Longest-Match dictionary lookup for 0.00% OOV and maximum semantic representation.
|
| 339 |
+
"""
|
| 340 |
+
def __init__(self, vocab_json_path: str):
|
| 341 |
+
with open(vocab_json_path, "r", encoding="utf-8") as f:
|
| 342 |
+
data = json.load(f)
|
| 343 |
+
|
| 344 |
+
self.token_to_id: Dict[str, int] = data["token_to_id"]
|
| 345 |
+
# Convert integer keys back if serialized as strings in json
|
| 346 |
+
self.id_to_token: Dict[int, str] = {int(k): v for k, v in data["id_to_token"].items()}
|
| 347 |
+
self.blank_id: int = data.get("blank_id", 0)
|
| 348 |
+
self.blank_token: str = data.get("blank_token", "<blank>")
|
| 349 |
+
self.unk_id: int = data.get("unk_id", 1)
|
| 350 |
+
self.unk_token: str = data.get("unk_token", "<unk>")
|
| 351 |
+
self.word_prefix: str = WORD_PREFIX
|
| 352 |
+
self._max_tok_len: int = max((len(k) for k in self.token_to_id.keys()), default=16)
|
| 353 |
+
self._word_id_cache: Dict[str, Tuple[int, ...]] = {}
|
| 354 |
+
|
| 355 |
+
def _encode_word(self, word: str) -> Tuple[int, ...]:
|
| 356 |
+
cached = self._word_id_cache.get(word)
|
| 357 |
+
if cached is not None:
|
| 358 |
+
return cached
|
| 359 |
+
w_len = len(word)
|
| 360 |
+
pos = 0
|
| 361 |
+
first_in_word = True
|
| 362 |
+
ids: List[int] = []
|
| 363 |
+
t2i = self.token_to_id
|
| 364 |
+
wp = self.word_prefix
|
| 365 |
+
max_tl = self._max_tok_len
|
| 366 |
+
unk = self.unk_id
|
| 367 |
+
|
| 368 |
+
while pos < w_len:
|
| 369 |
+
matched = False
|
| 370 |
+
max_end = w_len if w_len - pos <= max_tl else pos + max_tl
|
| 371 |
+
for end in range(max_end, pos, -1):
|
| 372 |
+
sub = word[pos:end]
|
| 373 |
+
cand = (wp + sub) if first_in_word else sub
|
| 374 |
+
tid = t2i.get(cand)
|
| 375 |
+
if tid is not None:
|
| 376 |
+
ids.append(tid)
|
| 377 |
+
pos = end
|
| 378 |
+
first_in_word = False
|
| 379 |
+
matched = True
|
| 380 |
+
break
|
| 381 |
+
if not matched:
|
| 382 |
+
sub = word[pos:pos + 1]
|
| 383 |
+
cand = (wp + sub) if first_in_word else sub
|
| 384 |
+
ids.append(t2i.get(cand, unk))
|
| 385 |
+
pos += 1
|
| 386 |
+
first_in_word = False
|
| 387 |
+
|
| 388 |
+
res = tuple(ids)
|
| 389 |
+
if len(self._word_id_cache) < 65536:
|
| 390 |
+
self._word_id_cache[word] = res
|
| 391 |
+
return res
|
| 392 |
+
|
| 393 |
+
def encode(self, text: str) -> List[int]:
|
| 394 |
+
"""
|
| 395 |
+
Tokenizes text into surface morpheme tokens (with syllable fallback) and maps them to token IDs.
|
| 396 |
+
"""
|
| 397 |
+
clean_text = normalize_text(text)
|
| 398 |
+
if not clean_text:
|
| 399 |
+
return []
|
| 400 |
+
out_ids: List[int] = []
|
| 401 |
+
for word in clean_text.split(" "):
|
| 402 |
+
if word:
|
| 403 |
+
out_ids.extend(self._encode_word(word))
|
| 404 |
+
return out_ids
|
| 405 |
+
|
| 406 |
+
def decode(self, token_ids: List[int], remove_blank: bool = True, collapse_repeats: bool = True) -> str:
|
| 407 |
+
"""
|
| 408 |
+
Decodes a sequence of token IDs into text.
|
| 409 |
+
For CTC output:
|
| 410 |
+
- collapse_repeats: collapses consecutive duplicate token IDs
|
| 411 |
+
- remove_blank: removes blank_id (0)
|
| 412 |
+
- word boundary reconstruction: reconstructs spaces between words using prefix ' ' or ' ▁'
|
| 413 |
+
"""
|
| 414 |
+
if collapse_repeats:
|
| 415 |
+
collapsed = []
|
| 416 |
+
prev = None
|
| 417 |
+
for tid in token_ids:
|
| 418 |
+
if tid != prev:
|
| 419 |
+
collapsed.append(tid)
|
| 420 |
+
prev = tid
|
| 421 |
+
token_ids = collapsed
|
| 422 |
+
|
| 423 |
+
words = []
|
| 424 |
+
for tid in token_ids:
|
| 425 |
+
if remove_blank and tid == self.blank_id:
|
| 426 |
+
continue
|
| 427 |
+
token_str = self.id_to_token.get(tid, "")
|
| 428 |
+
if token_str and token_str != self.blank_token and token_str != self.unk_token:
|
| 429 |
+
words.append(token_str)
|
| 430 |
+
|
| 431 |
+
res = ""
|
| 432 |
+
for w in words:
|
| 433 |
+
is_ws, clean_w = strip_word_prefix(w)
|
| 434 |
+
if is_ws:
|
| 435 |
+
if res:
|
| 436 |
+
res += " " + clean_w
|
| 437 |
+
else:
|
| 438 |
+
res = clean_w
|
| 439 |
+
else:
|
| 440 |
+
res += clean_w
|
| 441 |
+
|
| 442 |
+
return res.strip()
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
_CONTRACTIONS: Tuple[Tuple[str, str], ...] = (
|
| 446 |
+
("하었", "했"), ("하 었", "했"), ("되었", "됐"), ("되 었", "됐"),
|
| 447 |
+
("나오었", "나왔"), ("나오 었", "나왔"), ("보았", "봤"), ("보 었", "봤"),
|
| 448 |
+
("주었", "줬"), ("주 었", "줬"), ("오었", "왔"), ("오 었", "왔"),
|
| 449 |
+
("가었", "갔"), ("가 었", "갔"), ("지었", "졌"), ("지 었", "졌"),
|
| 450 |
+
("받아들이어", "받아들여")
|
| 451 |
+
)
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
# ==============================================================================
|
| 455 |
+
# 2. Cached Audio Feature Dataset & Length-Bucketed Batch Sampler
|
| 456 |
+
# ==============================================================================
|
| 457 |
+
|
| 458 |
+
class _SharedFilePathAccessor:
|
| 459 |
+
"""Zero-RAM sequence wrapper over shared-memory byte & offset tensors."""
|
| 460 |
+
def __init__(self, base_dir: str, names_buf: torch.Tensor, offsets_buf: torch.Tensor):
|
| 461 |
+
self.base_dir = base_dir
|
| 462 |
+
self.names_buf = names_buf
|
| 463 |
+
self.offsets_buf = offsets_buf
|
| 464 |
+
|
| 465 |
+
def __len__(self) -> int:
|
| 466 |
+
return max(0, int(self.offsets_buf.numel()) - 1)
|
| 467 |
+
|
| 468 |
+
def get_name(self, idx: int) -> str:
|
| 469 |
+
s = int(self.offsets_buf[idx].item())
|
| 470 |
+
e = int(self.offsets_buf[idx + 1].item())
|
| 471 |
+
return bytes(self.names_buf[s:e].numpy()).decode("utf-8")
|
| 472 |
+
|
| 473 |
+
def __getitem__(self, idx: int) -> str:
|
| 474 |
+
return os.path.join(self.base_dir, self.get_name(idx))
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
class CachedAudioFeatureDataset(Dataset):
|
| 478 |
+
"""
|
| 479 |
+
Loads pre-extracted (Time, 256) audio encoder representations and target token IDs.
|
| 480 |
+
Uses OS shared-memory tensors (.share_memory_()) for file names and sizes so DataLoader
|
| 481 |
+
worker processes share a single 68MB index with zero Copy-on-Write or pickling bloat.
|
| 482 |
+
"""
|
| 483 |
+
def __init__(
|
| 484 |
+
self,
|
| 485 |
+
cache_dir: str,
|
| 486 |
+
tokenizer: Optional[KoreanMorphemeTokenizer] = None,
|
| 487 |
+
max_files: Optional[int] = None
|
| 488 |
+
):
|
| 489 |
+
super().__init__()
|
| 490 |
+
self.cache_dir = Path(cache_dir)
|
| 491 |
+
self.cache_dir_str = str(self.cache_dir)
|
| 492 |
+
self.tokenizer = tokenizer
|
| 493 |
+
|
| 494 |
+
index_cache_file = self.cache_dir.parent / "vocab" / "feature_cache_index.pt"
|
| 495 |
+
loaded_from_cache = False
|
| 496 |
+
|
| 497 |
+
if max_files is None and index_cache_file.exists():
|
| 498 |
+
try:
|
| 499 |
+
cached_idx = torch.load(index_cache_file, map_location="cpu", weights_only=False)
|
| 500 |
+
if (
|
| 501 |
+
isinstance(cached_idx, dict)
|
| 502 |
+
and cached_idx.get("cache_dir") == self.cache_dir_str
|
| 503 |
+
and int(cached_idx.get("count", 0)) > 0
|
| 504 |
+
):
|
| 505 |
+
self._names_buf = cached_idx["names_buf"].share_memory_()
|
| 506 |
+
self._offsets_buf = cached_idx["offsets_buf"].share_memory_()
|
| 507 |
+
self.file_sizes = cached_idx["sizes_buf"].share_memory_()
|
| 508 |
+
loaded_from_cache = True
|
| 509 |
+
except Exception:
|
| 510 |
+
loaded_from_cache = False
|
| 511 |
+
|
| 512 |
+
if not loaded_from_cache:
|
| 513 |
+
names_list: List[bytes] = []
|
| 514 |
+
sizes_list: List[int] = []
|
| 515 |
+
if self.cache_dir.exists():
|
| 516 |
+
entries = [e for e in os.scandir(self.cache_dir) if e.name.endswith(".pt")]
|
| 517 |
+
entries.sort(key=lambda e: e.name)
|
| 518 |
+
if max_files is not None and max_files > 0:
|
| 519 |
+
entries = entries[:max_files]
|
| 520 |
+
names_list = [e.name.encode("utf-8") for e in entries]
|
| 521 |
+
sizes_list = [e.stat().st_size for e in entries]
|
| 522 |
+
|
| 523 |
+
if names_list:
|
| 524 |
+
lengths = [len(b) for b in names_list]
|
| 525 |
+
offsets = [0]
|
| 526 |
+
acc = 0
|
| 527 |
+
for ln in lengths:
|
| 528 |
+
acc += ln
|
| 529 |
+
offsets.append(acc)
|
| 530 |
+
joined = b"".join(names_list)
|
| 531 |
+
self._names_buf = torch.frombuffer(bytearray(joined), dtype=torch.uint8).share_memory_()
|
| 532 |
+
self._offsets_buf = torch.tensor(offsets, dtype=torch.int64).share_memory_()
|
| 533 |
+
self.file_sizes = torch.tensor(sizes_list, dtype=torch.int32).share_memory_()
|
| 534 |
+
|
| 535 |
+
if max_files is None:
|
| 536 |
+
try:
|
| 537 |
+
index_cache_file.parent.mkdir(parents=True, exist_ok=True)
|
| 538 |
+
tmp_idx = index_cache_file.with_suffix(".pt.tmp")
|
| 539 |
+
torch.save(
|
| 540 |
+
{
|
| 541 |
+
"cache_dir": self.cache_dir_str,
|
| 542 |
+
"count": len(names_list),
|
| 543 |
+
"names_buf": self._names_buf,
|
| 544 |
+
"offsets_buf": self._offsets_buf,
|
| 545 |
+
"sizes_buf": self.file_sizes,
|
| 546 |
+
},
|
| 547 |
+
tmp_idx
|
| 548 |
+
)
|
| 549 |
+
tmp_idx.replace(index_cache_file)
|
| 550 |
+
except Exception:
|
| 551 |
+
pass
|
| 552 |
+
else:
|
| 553 |
+
self._names_buf = torch.empty((0,), dtype=torch.uint8).share_memory_()
|
| 554 |
+
self._offsets_buf = torch.zeros((1,), dtype=torch.int64).share_memory_()
|
| 555 |
+
self.file_sizes = torch.empty((0,), dtype=torch.int32).share_memory_()
|
| 556 |
+
print(f"[Dataset Warning] No cached .pt files found in {self.cache_dir}.")
|
| 557 |
+
|
| 558 |
+
self.file_paths = _SharedFilePathAccessor(self.cache_dir_str, self._names_buf, self._offsets_buf)
|
| 559 |
+
|
| 560 |
+
def __len__(self) -> int:
|
| 561 |
+
return int(self.file_sizes.numel())
|
| 562 |
+
|
| 563 |
+
def __getitem__(self, idx: int) -> Dict[str, Any]:
|
| 564 |
+
fname = self.file_paths.get_name(idx)
|
| 565 |
+
file_path = os.path.join(self.cache_dir_str, fname)
|
| 566 |
+
data = torch.load(file_path, map_location="cpu", weights_only=False)
|
| 567 |
+
feature = data["feature"] # Shape: (T, 256)
|
| 568 |
+
raw_text = data.get("raw_text", "")
|
| 569 |
+
|
| 570 |
+
if self.tokenizer is not None and raw_text:
|
| 571 |
+
tok_ids = self.tokenizer.encode(raw_text)
|
| 572 |
+
targets = torch.tensor(tok_ids, dtype=torch.long)
|
| 573 |
+
elif "targets" in data and isinstance(data["targets"], torch.Tensor):
|
| 574 |
+
targets = data["targets"].to(torch.long)
|
| 575 |
+
else:
|
| 576 |
+
targets = torch.tensor([], dtype=torch.long)
|
| 577 |
+
|
| 578 |
+
if not isinstance(feature, torch.Tensor):
|
| 579 |
+
feature = torch.tensor(feature, dtype=torch.bfloat16)
|
| 580 |
+
|
| 581 |
+
# Strictly enforce bfloat16 (prevent MPS float16 underflow and float32 NaN instability)
|
| 582 |
+
if feature.dtype != torch.bfloat16:
|
| 583 |
+
feature = feature.to(torch.bfloat16)
|
| 584 |
+
|
| 585 |
+
return {
|
| 586 |
+
"feature": feature,
|
| 587 |
+
"targets": targets,
|
| 588 |
+
"raw_text": raw_text,
|
| 589 |
+
"file_name": fname
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
|
| 593 |
+
class LengthBucketBatchSampler(torch.utils.data.Sampler[List[int]]):
|
| 594 |
+
"""
|
| 595 |
+
Groups samples of similar sequence lengths into batches using cached .pt file sizes
|
| 596 |
+
as a zero-I/O O(1) proxy for frame length T (~512 bytes per time frame).
|
| 597 |
+
Eliminates ~40% random-batching padding overhead while preserving stochasticity
|
| 598 |
+
via mega-batch pool sorting and batch-order shuffling each epoch.
|
| 599 |
+
"""
|
| 600 |
+
def __init__(
|
| 601 |
+
self,
|
| 602 |
+
dataset: Dataset,
|
| 603 |
+
batch_size: int,
|
| 604 |
+
shuffle: bool = True,
|
| 605 |
+
pool_batches: int = 128,
|
| 606 |
+
seed: int = 42
|
| 607 |
+
):
|
| 608 |
+
super().__init__()
|
| 609 |
+
self.dataset = dataset
|
| 610 |
+
self.batch_size = max(1, batch_size)
|
| 611 |
+
self.shuffle = shuffle
|
| 612 |
+
self.pool_size = self.batch_size * max(1, pool_batches)
|
| 613 |
+
self.seed = seed
|
| 614 |
+
self.epoch = 0
|
| 615 |
+
self.start_batch = 0
|
| 616 |
+
|
| 617 |
+
# Resolve per-item file size proxy as compact int32 tensor
|
| 618 |
+
if hasattr(dataset, "file_sizes"):
|
| 619 |
+
fs = dataset.file_sizes
|
| 620 |
+
self.lengths_t = fs if isinstance(fs, torch.Tensor) else torch.tensor(fs, dtype=torch.int32)
|
| 621 |
+
elif hasattr(dataset, "dataset") and hasattr(dataset, "indices") and hasattr(dataset.dataset, "file_sizes"):
|
| 622 |
+
base_sizes = dataset.dataset.file_sizes
|
| 623 |
+
base_t = base_sizes if isinstance(base_sizes, torch.Tensor) else torch.tensor(base_sizes, dtype=torch.int32)
|
| 624 |
+
idx_t = dataset.indices if isinstance(dataset.indices, torch.Tensor) else torch.tensor(dataset.indices, dtype=torch.long)
|
| 625 |
+
self.lengths_t = base_t[idx_t]
|
| 626 |
+
else:
|
| 627 |
+
self.lengths_t = torch.zeros((len(dataset),), dtype=torch.int32)
|
| 628 |
+
|
| 629 |
+
def set_epoch(self, epoch: int, start_batch: int = 0) -> None:
|
| 630 |
+
"""Sets current epoch (0-indexed) and optional starting batch offset for zero-I/O resume."""
|
| 631 |
+
self.epoch = max(0, int(epoch))
|
| 632 |
+
self.start_batch = max(0, int(start_batch))
|
| 633 |
+
|
| 634 |
+
def __len__(self) -> int:
|
| 635 |
+
n = int(self.lengths_t.numel())
|
| 636 |
+
total = (n + self.batch_size - 1) // self.batch_size
|
| 637 |
+
return max(0, total - self.start_batch)
|
| 638 |
+
|
| 639 |
+
def __iter__(self):
|
| 640 |
+
n = int(self.lengths_t.numel())
|
| 641 |
+
start_b = self.start_batch
|
| 642 |
+
self.start_batch = 0 # Reset after consuming once
|
| 643 |
+
|
| 644 |
+
if self.shuffle:
|
| 645 |
+
g = torch.Generator()
|
| 646 |
+
g.manual_seed(self.seed + self.epoch)
|
| 647 |
+
self.epoch += 1
|
| 648 |
+
perm = torch.randperm(n, generator=g)
|
| 649 |
+
|
| 650 |
+
# Vectorized mega-pool sorting in C++ (torch.argsort) instead of Python list sorting
|
| 651 |
+
num_full_pools = n // self.pool_size
|
| 652 |
+
if num_full_pools > 0:
|
| 653 |
+
full_len = num_full_pools * self.pool_size
|
| 654 |
+
perm_main = perm[:full_len].view(num_full_pools, self.pool_size)
|
| 655 |
+
pool_lengths = self.lengths_t[perm_main]
|
| 656 |
+
sort_idx = torch.argsort(pool_lengths, dim=1, stable=True)
|
| 657 |
+
sorted_main = torch.gather(perm_main, 1, sort_idx).reshape(-1)
|
| 658 |
+
if full_len < n:
|
| 659 |
+
rem = perm[full_len:]
|
| 660 |
+
rem_sorted = rem[torch.argsort(self.lengths_t[rem], stable=True)]
|
| 661 |
+
sorted_indices = torch.cat([sorted_main, rem_sorted], dim=0)
|
| 662 |
+
else:
|
| 663 |
+
sorted_indices = sorted_main
|
| 664 |
+
else:
|
| 665 |
+
sorted_indices = perm[torch.argsort(self.lengths_t[perm], stable=True)]
|
| 666 |
+
|
| 667 |
+
all_indices = sorted_indices.tolist()
|
| 668 |
+
batches = [
|
| 669 |
+
all_indices[b_start : b_start + self.batch_size]
|
| 670 |
+
for b_start in range(0, n, self.batch_size)
|
| 671 |
+
]
|
| 672 |
+
|
| 673 |
+
b_perm = torch.randperm(len(batches), generator=g).tolist()
|
| 674 |
+
for bi in b_perm[start_b:]:
|
| 675 |
+
yield batches[bi]
|
| 676 |
+
else:
|
| 677 |
+
sorted_indices = torch.argsort(self.lengths_t, stable=True).tolist()
|
| 678 |
+
all_batches = [
|
| 679 |
+
sorted_indices[b_start : b_start + self.batch_size]
|
| 680 |
+
for b_start in range(0, n, self.batch_size)
|
| 681 |
+
]
|
| 682 |
+
for b in all_batches[start_b:]:
|
| 683 |
+
yield b
|
| 684 |
+
|
| 685 |
+
|
| 686 |
+
# ==============================================================================
|
| 687 |
+
# 3. Dynamic Padding Collate Function for CTC
|
| 688 |
+
# ==============================================================================
|
| 689 |
+
|
| 690 |
+
class PadCollate:
|
| 691 |
+
"""
|
| 692 |
+
Collates a list of variable-length audio features and targets:
|
| 693 |
+
- Caps outlier audio lengths to max_frames (default 512 = ~20.5s) to prevent MPS OOM spikes
|
| 694 |
+
- Pads features to maximum time length in the batch -> (Batch, T_max, 256)
|
| 695 |
+
- Generates key_padding_mask -> (Batch, T_max) for Conformer attention
|
| 696 |
+
- Concatenates flat_targets for CTC Loss
|
| 697 |
+
- Pre-computes active_ids (int32) and compact_targets (int64) in background DataLoader workers
|
| 698 |
+
for FusedMetalCosineCTCLoss
|
| 699 |
+
"""
|
| 700 |
+
def __init__(
|
| 701 |
+
self,
|
| 702 |
+
pad_feature_value: float = 0.0,
|
| 703 |
+
pad_target_value: int = 0,
|
| 704 |
+
pad_multiple: int = 16,
|
| 705 |
+
max_frames: int = 512
|
| 706 |
+
):
|
| 707 |
+
self.pad_feature_val = pad_feature_value
|
| 708 |
+
self.pad_target_val = pad_target_value
|
| 709 |
+
self.pad_multiple = pad_multiple
|
| 710 |
+
self.max_frames = max_frames
|
| 711 |
+
|
| 712 |
+
def __call__(self, batch: List[Dict[str, Any]]) -> Dict[str, Any]:
|
| 713 |
+
# Filter out corrupted, empty, or CTC-invalid (T < L) entries
|
| 714 |
+
batch = [
|
| 715 |
+
item for item in batch
|
| 716 |
+
if item["feature"].size(0) > 0 and 0 < item["targets"].size(0) <= item["feature"].size(0)
|
| 717 |
+
]
|
| 718 |
+
if not batch:
|
| 719 |
+
return {}
|
| 720 |
+
|
| 721 |
+
# Cap outlier sequence lengths while guaranteeing CTC constraint (T >= target_len)
|
| 722 |
+
if self.max_frames and self.max_frames > 0:
|
| 723 |
+
for item in batch:
|
| 724 |
+
t_len = item["feature"].size(0)
|
| 725 |
+
if t_len > self.max_frames:
|
| 726 |
+
safe_cap = max(self.max_frames, int(item["targets"].size(0)) + 16)
|
| 727 |
+
if t_len > safe_cap:
|
| 728 |
+
item["feature"] = item["feature"][:safe_cap]
|
| 729 |
+
|
| 730 |
+
batch_size = len(batch)
|
| 731 |
+
feat_dim = batch[0]["feature"].size(-1)
|
| 732 |
+
dtype = batch[0]["feature"].dtype
|
| 733 |
+
|
| 734 |
+
# Extract sequence lengths
|
| 735 |
+
input_lengths = torch.tensor([item["feature"].size(0) for item in batch], dtype=torch.long)
|
| 736 |
+
target_lengths = torch.tensor([item["targets"].size(0) for item in batch], dtype=torch.long)
|
| 737 |
+
|
| 738 |
+
actual_max_t = int(input_lengths.max().item())
|
| 739 |
+
max_t = actual_max_t
|
| 740 |
+
if self.pad_multiple > 1:
|
| 741 |
+
max_t = ((max_t + self.pad_multiple - 1) // self.pad_multiple) * self.pad_multiple
|
| 742 |
+
|
| 743 |
+
# Pad features: (B, max_t, 256)
|
| 744 |
+
padded_features = torch.full((batch_size, max_t, feat_dim), self.pad_feature_val, dtype=dtype)
|
| 745 |
+
# Key padding mask: True for padded positions (B, max_t)
|
| 746 |
+
key_padding_mask = torch.zeros((batch_size, max_t), dtype=torch.bool)
|
| 747 |
+
|
| 748 |
+
for i, item in enumerate(batch):
|
| 749 |
+
t_len = item["feature"].size(0)
|
| 750 |
+
padded_features[i, :t_len] = item["feature"]
|
| 751 |
+
if t_len < max_t:
|
| 752 |
+
key_padding_mask[i, t_len:] = True
|
| 753 |
+
|
| 754 |
+
# Flattened targets for standard PyTorch CTCLoss compatibility: (sum(target_lengths),)
|
| 755 |
+
flat_targets = torch.cat([item["targets"] for item in batch], dim=0)
|
| 756 |
+
|
| 757 |
+
# Pre-compute unique active target IDs and compact targets in DataLoader worker thread
|
| 758 |
+
uniq_t, inv_idx = torch.unique(flat_targets, sorted=True, return_inverse=True)
|
| 759 |
+
active_ids = torch.cat([torch.zeros(1, dtype=torch.int32), uniq_t.to(torch.int32)])
|
| 760 |
+
compact_targets = inv_idx + 1
|
| 761 |
+
|
| 762 |
+
raw_texts = [item.get("raw_text", "") for item in batch]
|
| 763 |
+
|
| 764 |
+
return {
|
| 765 |
+
"features": padded_features, # (B, T_max, 256)
|
| 766 |
+
"input_lengths": input_lengths, # (B,)
|
| 767 |
+
"max_valid_len": actual_max_t, # int (unbucketed max T in batch)
|
| 768 |
+
"key_padding_mask": key_padding_mask, # (B, T_max)
|
| 769 |
+
"flat_targets": flat_targets, # (sum(L),)
|
| 770 |
+
"active_ids": active_ids, # (U,) int32
|
| 771 |
+
"compact_targets": compact_targets, # (sum(L),) int64
|
| 772 |
+
"target_lengths": target_lengths, # (B,)
|
| 773 |
+
"raw_texts": raw_texts
|
| 774 |
+
}
|
src/decoder.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
src/decoder.py
|
| 3 |
+
|
| 4 |
+
Production On-Device CTC Beam Search Decoder with KenLM Language Model Rescoring.
|
| 5 |
+
Integrates Korean Morpheme Tokenizer, Trie N-Gram language model, and contraction rules.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import math
|
| 11 |
+
import ctypes
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import List, Dict, Tuple, Optional, Any
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import torch
|
| 17 |
+
import torch.nn.functional as F
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
import kenlm
|
| 21 |
+
except ImportError:
|
| 22 |
+
kenlm = None
|
| 23 |
+
|
| 24 |
+
from dataset import KoreanMorphemeTokenizer, normalize_text
|
| 25 |
+
|
| 26 |
+
# CTC Beam Search Engine with KenLM Language Model
|
| 27 |
+
_FAST_BEAM_LIB = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _log_add(a: float, b: float) -> float:
|
| 31 |
+
"""Numerically stable log-sum-exp for two scalars: log(exp(a) + exp(b))."""
|
| 32 |
+
if a <= -1e20:
|
| 33 |
+
return b
|
| 34 |
+
if b <= -1e20:
|
| 35 |
+
return a
|
| 36 |
+
diff = a - b
|
| 37 |
+
if diff > 50.0:
|
| 38 |
+
return a
|
| 39 |
+
if diff < -50.0:
|
| 40 |
+
return b
|
| 41 |
+
return a + math.log1p(math.exp(-diff)) if diff >= 0 else b + math.log1p(math.exp(diff))
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class KenLMBeamSearchDecoder:
|
| 45 |
+
"""
|
| 46 |
+
On-device CTC Beam Search Decoder equipped with KenLM Language Model:
|
| 47 |
+
- Maintains top-K acoustic beams across temporal frames
|
| 48 |
+
- Rescores word sequences using KenLM 3-gram language model
|
| 49 |
+
- Integrates pure syllable decoding and batchim/contraction rules
|
| 50 |
+
- Runs in pure CPU C++ bindings in ~5-15ms per utterance
|
| 51 |
+
"""
|
| 52 |
+
def __init__(
|
| 53 |
+
self,
|
| 54 |
+
tokenizer: KoreanMorphemeTokenizer,
|
| 55 |
+
lm_path: Optional[str] = "data/vocab/korean_lm.bin",
|
| 56 |
+
beam_size: int = 8,
|
| 57 |
+
alpha: float = 0.5, # Language model weight
|
| 58 |
+
beta: float = 1.0, # Word insertion bonus
|
| 59 |
+
blank_id: int = 0
|
| 60 |
+
):
|
| 61 |
+
self.tokenizer = tokenizer
|
| 62 |
+
self.beam_size = max(1, beam_size)
|
| 63 |
+
self.alpha = alpha
|
| 64 |
+
self.beta = beta
|
| 65 |
+
self.blank_id = blank_id
|
| 66 |
+
|
| 67 |
+
self.lm = None
|
| 68 |
+
if lm_path and kenlm is not None:
|
| 69 |
+
p = Path(lm_path)
|
| 70 |
+
if not p.is_absolute():
|
| 71 |
+
p = Path(__file__).resolve().parent.parent / p
|
| 72 |
+
if p.exists():
|
| 73 |
+
self.lm = kenlm.LanguageModel(str(p))
|
| 74 |
+
print(f"[Decoder] Loaded KenLM language model ({p.stat().st_size / (1024*1024):.1f} MB) from {p.name}")
|
| 75 |
+
else:
|
| 76 |
+
print(f"[Decoder Warning] KenLM file not found at {p}. Falling back to acoustic beam search.")
|
| 77 |
+
|
| 78 |
+
def decode_greedy(self, logits: torch.Tensor) -> str:
|
| 79 |
+
"""Standard Argmax Greedy Decoding (baseline)."""
|
| 80 |
+
preds = torch.argmax(logits, dim=-1).cpu().tolist()
|
| 81 |
+
return self.tokenizer.decode(preds, remove_blank=True, collapse_repeats=True)
|
| 82 |
+
|
| 83 |
+
def decode_beam(
|
| 84 |
+
self,
|
| 85 |
+
logits: torch.Tensor,
|
| 86 |
+
beam_size: Optional[int] = None,
|
| 87 |
+
alpha: Optional[float] = None,
|
| 88 |
+
beta: Optional[float] = None
|
| 89 |
+
) -> str:
|
| 90 |
+
"""
|
| 91 |
+
Executes Time-Synchronous CTC Beam Search with KenLM Rescoring.
|
| 92 |
+
Args:
|
| 93 |
+
logits: Tensor of shape (Time, Vocab) or (1, Time, Vocab)
|
| 94 |
+
Returns:
|
| 95 |
+
Best decoded Korean text
|
| 96 |
+
"""
|
| 97 |
+
if logits.dim() == 3:
|
| 98 |
+
logits = logits.squeeze(0)
|
| 99 |
+
|
| 100 |
+
k_beam = beam_size or self.beam_size
|
| 101 |
+
a = alpha if alpha is not None else self.alpha
|
| 102 |
+
b = beta if beta is not None else self.beta
|
| 103 |
+
|
| 104 |
+
# Convert logits to log-probabilities (Time, Vocab)
|
| 105 |
+
log_probs = F.log_softmax(logits, dim=-1).cpu()
|
| 106 |
+
T, V = log_probs.shape
|
| 107 |
+
|
| 108 |
+
# Beam state: (tuple_of_non_blank_tokens, last_token) -> (p_blank, p_non_blank)
|
| 109 |
+
# Empty hypothesis at t=0
|
| 110 |
+
beams: Dict[Tuple[int, ...], Tuple[float, float, int]] = {
|
| 111 |
+
(): (0.0, -float("inf"), self.blank_id)
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
# Consider top-N most likely tokens per frame to keep beam search blazing fast (<10ms)
|
| 115 |
+
top_k_candidates = min(40, V)
|
| 116 |
+
|
| 117 |
+
for t in range(T):
|
| 118 |
+
frame_probs = log_probs[t]
|
| 119 |
+
top_vals, top_indices = torch.topk(frame_probs, top_k_candidates)
|
| 120 |
+
top_vals = top_vals.tolist()
|
| 121 |
+
top_indices = top_indices.tolist()
|
| 122 |
+
|
| 123 |
+
next_beams: Dict[Tuple[int, ...], Tuple[float, float, int]] = {}
|
| 124 |
+
|
| 125 |
+
for prefix, (p_b, p_nb, last_tok) in beams.items():
|
| 126 |
+
p_total = math.log(math.exp(p_b) + (math.exp(p_nb) if p_nb > -float("inf") else 0.0) + 1e-30)
|
| 127 |
+
|
| 128 |
+
# 1. Transition to Blank
|
| 129 |
+
prob_blank = frame_probs[self.blank_id].item()
|
| 130 |
+
nb_p_b, nb_p_nb, _ = next_beams.get(prefix, (-float("inf"), -float("inf"), self.blank_id))
|
| 131 |
+
new_p_b = math.log(math.exp(nb_p_b) + math.exp(p_total + prob_blank) + 1e-30)
|
| 132 |
+
next_beams[prefix] = (new_p_b, nb_p_nb, self.blank_id)
|
| 133 |
+
|
| 134 |
+
# 2. Transition to Non-Blank tokens
|
| 135 |
+
for p_val, tok_id in zip(top_vals, top_indices):
|
| 136 |
+
if tok_id == self.blank_id:
|
| 137 |
+
continue
|
| 138 |
+
|
| 139 |
+
prob_tok = p_val
|
| 140 |
+
if tok_id == last_tok:
|
| 141 |
+
# Same token repeated immediately without blank: only extends if coming from blank
|
| 142 |
+
new_prefix = prefix
|
| 143 |
+
nb_p_b, nb_p_nb, _ = next_beams.get(new_prefix, (-float("inf"), -float("inf"), tok_id))
|
| 144 |
+
add_p = math.log(math.exp(nb_p_nb) + math.exp(p_nb + prob_tok) + 1e-30)
|
| 145 |
+
next_beams[new_prefix] = (nb_p_b, add_p, tok_id)
|
| 146 |
+
|
| 147 |
+
# Coming from blank creates new token
|
| 148 |
+
extended_prefix = prefix + (tok_id,)
|
| 149 |
+
enb_p_b, enb_p_nb, _ = next_beams.get(extended_prefix, (-float("inf"), -float("inf"), tok_id))
|
| 150 |
+
new_enb_p = math.log(math.exp(enb_p_nb) + math.exp(p_b + prob_tok) + 1e-30)
|
| 151 |
+
next_beams[extended_prefix] = (enb_p_b, new_enb_p, tok_id)
|
| 152 |
+
else:
|
| 153 |
+
new_prefix = prefix + (tok_id,)
|
| 154 |
+
nb_p_b, nb_p_nb, _ = next_beams.get(new_prefix, (-float("inf"), -float("inf"), tok_id))
|
| 155 |
+
new_p_nb = math.log(math.exp(nb_p_nb) + math.exp(p_total + prob_tok) + 1e-30)
|
| 156 |
+
next_beams[new_prefix] = (nb_p_b, new_p_nb, tok_id)
|
| 157 |
+
|
| 158 |
+
# Prune beams to top-k
|
| 159 |
+
def _score_beam(item):
|
| 160 |
+
pref, (pb, pnb, _) = item
|
| 161 |
+
acoustic_score = math.log(
|
| 162 |
+
(math.exp(pb) if pb > -float("inf") else 0.0) +
|
| 163 |
+
(math.exp(pnb) if pnb > -float("inf") else 0.0) +
|
| 164 |
+
1e-30
|
| 165 |
+
)
|
| 166 |
+
return acoustic_score
|
| 167 |
+
|
| 168 |
+
sorted_items = sorted(next_beams.items(), key=_score_beam, reverse=True)[: k_beam * 3]
|
| 169 |
+
beams = {pref: val for pref, val in sorted_items}
|
| 170 |
+
|
| 171 |
+
# Final Rescoring with KenLM
|
| 172 |
+
best_hypothesis = ""
|
| 173 |
+
best_total_score = -float("inf")
|
| 174 |
+
|
| 175 |
+
for prefix, (p_b, p_nb, _) in beams.items():
|
| 176 |
+
acoustic_score = _log_add(p_b, p_nb)
|
| 177 |
+
|
| 178 |
+
decoded_text = self.tokenizer.decode(list(prefix), remove_blank=True, collapse_repeats=False)
|
| 179 |
+
if not decoded_text:
|
| 180 |
+
continue
|
| 181 |
+
|
| 182 |
+
lm_score = 0.0
|
| 183 |
+
num_words = len(decoded_text.split())
|
| 184 |
+
if self.lm is not None:
|
| 185 |
+
# KenLM log10 score
|
| 186 |
+
lm_score = self.lm.score(decoded_text)
|
| 187 |
+
|
| 188 |
+
total_score = acoustic_score + (a * lm_score) + (b * num_words)
|
| 189 |
+
if total_score > best_total_score:
|
| 190 |
+
best_total_score = total_score
|
| 191 |
+
best_hypothesis = decoded_text
|
| 192 |
+
|
| 193 |
+
if not best_hypothesis:
|
| 194 |
+
# Fallback to greedy if all beams empty
|
| 195 |
+
best_hypothesis = self.decode_greedy(logits)
|
| 196 |
+
|
| 197 |
+
return best_hypothesis
|
| 198 |
+
|
| 199 |
+
def decode_topk(
|
| 200 |
+
self,
|
| 201 |
+
topk_ids: np.ndarray,
|
| 202 |
+
topk_logprobs: np.ndarray,
|
| 203 |
+
blank_logprobs: np.ndarray,
|
| 204 |
+
beam_size: Optional[int] = None,
|
| 205 |
+
alpha: Optional[float] = None,
|
| 206 |
+
beta: Optional[float] = None
|
| 207 |
+
) -> str:
|
| 208 |
+
"""
|
| 209 |
+
Ultra-fast Time-Synchronous CTC Beam Search:
|
| 210 |
+
- Uses Native C++ Rolling-Hash Engine (<2ms) if available
|
| 211 |
+
- Direct KenLM rescoring on candidate beams
|
| 212 |
+
"""
|
| 213 |
+
k_beam = beam_size or self.beam_size
|
| 214 |
+
a = alpha if alpha is not None else self.alpha
|
| 215 |
+
b = beta if beta is not None else self.beta
|
| 216 |
+
|
| 217 |
+
T, K = topk_ids.shape
|
| 218 |
+
|
| 219 |
+
# -------------------------------------------------------------
|
| 220 |
+
# C++ Ultra-Fast Fast-Path (< 2ms for 375 frames)
|
| 221 |
+
# -------------------------------------------------------------
|
| 222 |
+
if _FAST_BEAM_LIB is not None:
|
| 223 |
+
max_beams = max(16, k_beam * 2)
|
| 224 |
+
max_seq_len = 512
|
| 225 |
+
out_tokens = np.zeros((max_beams, max_seq_len), dtype=np.int32)
|
| 226 |
+
out_lens = np.zeros(max_beams, dtype=np.int32)
|
| 227 |
+
out_scores = np.zeros(max_beams, dtype=np.float32)
|
| 228 |
+
|
| 229 |
+
# Ensure contiguous memory
|
| 230 |
+
t_ids_c = np.ascontiguousarray(topk_ids, dtype=np.int32)
|
| 231 |
+
t_lprob_c = np.ascontiguousarray(topk_logprobs, dtype=np.float32)
|
| 232 |
+
blank_lp_c = np.ascontiguousarray(blank_logprobs, dtype=np.float32)
|
| 233 |
+
|
| 234 |
+
n_hyp = _FAST_BEAM_LIB.ctc_beam_search_fast_c(
|
| 235 |
+
t_ids_c.ctypes.data_as(ctypes.POINTER(ctypes.c_int32)),
|
| 236 |
+
t_lprob_c.ctypes.data_as(ctypes.POINTER(ctypes.c_float)),
|
| 237 |
+
blank_lp_c.ctypes.data_as(ctypes.POINTER(ctypes.c_float)),
|
| 238 |
+
T, K, k_beam, self.blank_id,
|
| 239 |
+
out_tokens.ctypes.data_as(ctypes.POINTER(ctypes.c_int32)),
|
| 240 |
+
out_lens.ctypes.data_as(ctypes.POINTER(ctypes.c_int32)),
|
| 241 |
+
out_scores.ctypes.data_as(ctypes.POINTER(ctypes.c_float)),
|
| 242 |
+
max_beams, max_seq_len
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
best_hypothesis = ""
|
| 246 |
+
best_total_score = -float("inf")
|
| 247 |
+
|
| 248 |
+
for i in range(n_hyp):
|
| 249 |
+
seq_l = out_lens[i]
|
| 250 |
+
if seq_l == 0:
|
| 251 |
+
continue
|
| 252 |
+
toks = out_tokens[i, :seq_l].tolist()
|
| 253 |
+
text = self.tokenizer.decode(toks, remove_blank=True, collapse_repeats=True)
|
| 254 |
+
lm_score = self.lm.score(text) if self.lm else 0.0
|
| 255 |
+
words = len(text.split())
|
| 256 |
+
score = out_scores[i] + (a * lm_score) + (b * words)
|
| 257 |
+
if score > best_total_score:
|
| 258 |
+
best_total_score = score
|
| 259 |
+
best_hypothesis = text
|
| 260 |
+
|
| 261 |
+
if best_hypothesis:
|
| 262 |
+
return best_hypothesis
|
| 263 |
+
|
| 264 |
+
# -------------------------------------------------------------
|
| 265 |
+
# Fallback Python Path
|
| 266 |
+
# -------------------------------------------------------------
|
| 267 |
+
beams: Dict[Tuple[int, ...], Tuple[float, float, int]] = {
|
| 268 |
+
(): (0.0, -float("inf"), self.blank_id)
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
for t in range(T):
|
| 272 |
+
p_blank = float(blank_logprobs[t])
|
| 273 |
+
t_ids = topk_ids[t]
|
| 274 |
+
t_lprobs = topk_logprobs[t]
|
| 275 |
+
|
| 276 |
+
next_beams: Dict[Tuple[int, ...], Tuple[float, float, int]] = {}
|
| 277 |
+
|
| 278 |
+
for prefix, (p_b, p_nb, last_tok) in beams.items():
|
| 279 |
+
p_total = _log_add(p_b, p_nb)
|
| 280 |
+
|
| 281 |
+
# 1. Transition to Blank
|
| 282 |
+
nb_p_b, nb_p_nb, _ = next_beams.get(prefix, (-float("inf"), -float("inf"), self.blank_id))
|
| 283 |
+
new_p_b = _log_add(nb_p_b, p_total + p_blank)
|
| 284 |
+
next_beams[prefix] = (new_p_b, nb_p_nb, self.blank_id)
|
| 285 |
+
|
| 286 |
+
# 2. Transition to Top-K Non-Blank tokens
|
| 287 |
+
for k_idx in range(K):
|
| 288 |
+
tok_id = int(t_ids[k_idx])
|
| 289 |
+
if tok_id == self.blank_id:
|
| 290 |
+
continue
|
| 291 |
+
prob_tok = float(t_lprobs[k_idx])
|
| 292 |
+
|
| 293 |
+
if tok_id == last_tok:
|
| 294 |
+
new_prefix = prefix
|
| 295 |
+
nb_p_b, nb_p_nb, _ = next_beams.get(new_prefix, (-float("inf"), -float("inf"), tok_id))
|
| 296 |
+
add_p = _log_add(nb_p_nb, p_nb + prob_tok)
|
| 297 |
+
next_beams[new_prefix] = (nb_p_b, add_p, tok_id)
|
| 298 |
+
|
| 299 |
+
extended_prefix = prefix + (tok_id,)
|
| 300 |
+
enb_p_b, enb_p_nb, _ = next_beams.get(extended_prefix, (-float("inf"), -float("inf"), tok_id))
|
| 301 |
+
new_enb_p = _log_add(enb_p_nb, p_b + prob_tok)
|
| 302 |
+
next_beams[extended_prefix] = (enb_p_b, new_enb_p, tok_id)
|
| 303 |
+
else:
|
| 304 |
+
new_prefix = prefix + (tok_id,)
|
| 305 |
+
nb_p_b, nb_p_nb, _ = next_beams.get(new_prefix, (-float("inf"), -float("inf"), tok_id))
|
| 306 |
+
new_p_nb = _log_add(nb_p_nb, p_total + prob_tok)
|
| 307 |
+
next_beams[new_prefix] = (nb_p_b, new_p_nb, tok_id)
|
| 308 |
+
|
| 309 |
+
def _score_beam(item):
|
| 310 |
+
pref, (pb, pnb, _) = item
|
| 311 |
+
return _log_add(pb, pnb)
|
| 312 |
+
|
| 313 |
+
sorted_items = sorted(next_beams.items(), key=_score_beam, reverse=True)[: k_beam * 3]
|
| 314 |
+
beams = {pref: val for pref, val in sorted_items}
|
| 315 |
+
|
| 316 |
+
best_hypothesis = ""
|
| 317 |
+
best_total_score = -float("inf")
|
| 318 |
+
|
| 319 |
+
for prefix, (p_b, p_nb, _) in beams.items():
|
| 320 |
+
acoustic_score = _log_add(p_b, p_nb)
|
| 321 |
+
|
| 322 |
+
decoded_text = self.tokenizer.decode(list(prefix), remove_blank=True, collapse_repeats=False)
|
| 323 |
+
if not decoded_text:
|
| 324 |
+
continue
|
| 325 |
+
|
| 326 |
+
lm_score = 0.0
|
| 327 |
+
num_words = len(decoded_text.split())
|
| 328 |
+
if self.lm is not None:
|
| 329 |
+
lm_score = self.lm.score(decoded_text)
|
| 330 |
+
|
| 331 |
+
total_score = acoustic_score + (a * lm_score) + (b * num_words)
|
| 332 |
+
if total_score > best_total_score:
|
| 333 |
+
best_total_score = total_score
|
| 334 |
+
best_hypothesis = decoded_text
|
| 335 |
+
|
| 336 |
+
return best_hypothesis
|
src/model.py
ADDED
|
@@ -0,0 +1,783 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
src/model.py
|
| 3 |
+
|
| 4 |
+
Non-Autoregressive On-Device CTC Reader Architecture for EmbeddingGemma-2.
|
| 5 |
+
Maps frozen 300M Audio Encoder representations (MRL 256-dim) to the shared multimodal
|
| 6 |
+
vector space, matching fixed text embeddings via cosine similarity projection.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import math
|
| 12 |
+
import random
|
| 13 |
+
from typing import Optional, Tuple, Dict, Any
|
| 14 |
+
|
| 15 |
+
import torch
|
| 16 |
+
import torch.nn as nn
|
| 17 |
+
import torch.nn.functional as F
|
| 18 |
+
|
| 19 |
+
# Standard PyTorch Execution Mode
|
| 20 |
+
MetalFusedConvMidFunction = None
|
| 21 |
+
MetalFusedQKVLinearFunction = None
|
| 22 |
+
MetalFusedResL2NormFunction = None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ==============================================================================
|
| 26 |
+
# 1. Conformer / Transformer Building Blocks (Mobile-Friendly Standard Ops)
|
| 27 |
+
# ==============================================================================
|
| 28 |
+
|
| 29 |
+
class DepthwiseConv1d(nn.Module):
|
| 30 |
+
"""
|
| 31 |
+
Depthwise separable 1D convolution block with GLU and BatchNorm,
|
| 32 |
+
providing temporal context modeling without heavy parameter overhead.
|
| 33 |
+
Designed with standard PyTorch ops for seamless CoreML / ONNX export.
|
| 34 |
+
"""
|
| 35 |
+
def __init__(self, channels: int, kernel_size: int = 15, dropout: float = 0.1):
|
| 36 |
+
super().__init__()
|
| 37 |
+
assert kernel_size % 2 == 1, "Kernel size must be odd for same-padding"
|
| 38 |
+
self.padding = kernel_size // 2
|
| 39 |
+
|
| 40 |
+
self.pointwise_in = nn.Conv1d(channels, channels * 2, kernel_size=1)
|
| 41 |
+
self.act_glu = nn.GLU(dim=1)
|
| 42 |
+
self.depthwise = nn.Conv1d(
|
| 43 |
+
channels, channels,
|
| 44 |
+
kernel_size=kernel_size,
|
| 45 |
+
padding=self.padding,
|
| 46 |
+
groups=channels,
|
| 47 |
+
bias=False
|
| 48 |
+
)
|
| 49 |
+
self.bn = nn.BatchNorm1d(channels)
|
| 50 |
+
self.act_swish = nn.SiLU()
|
| 51 |
+
self.pointwise_out = nn.Conv1d(channels, channels, kernel_size=1)
|
| 52 |
+
self.dropout = nn.Dropout(dropout)
|
| 53 |
+
|
| 54 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 55 |
+
# Fast-path on Apple Silicon MPS with bfloat16: F.linear pointwise + Fused Metal GLU + DWConv15 + BN + SiLU
|
| 56 |
+
if (
|
| 57 |
+
x.device.type == "mps"
|
| 58 |
+
and x.dtype == torch.bfloat16
|
| 59 |
+
and self.depthwise.kernel_size[0] == 15
|
| 60 |
+
and MetalFusedConvMidFunction is not None
|
| 61 |
+
and not torch.jit.is_tracing()
|
| 62 |
+
and not torch.onnx.is_in_onnx_export()
|
| 63 |
+
):
|
| 64 |
+
res = x
|
| 65 |
+
u = F.linear(x, self.pointwise_in.weight.squeeze(-1), self.pointwise_in.bias)
|
| 66 |
+
y = MetalFusedConvMidFunction.apply(
|
| 67 |
+
u,
|
| 68 |
+
self.depthwise.weight,
|
| 69 |
+
self.bn.weight,
|
| 70 |
+
self.bn.bias,
|
| 71 |
+
self.bn.running_mean,
|
| 72 |
+
self.bn.running_var,
|
| 73 |
+
self.training
|
| 74 |
+
)
|
| 75 |
+
y = F.linear(y, self.pointwise_out.weight.squeeze(-1), self.pointwise_out.bias)
|
| 76 |
+
return res + self.dropout(y)
|
| 77 |
+
|
| 78 |
+
# Standard mobile-exportable path: (Batch, Time, Channels) -> transpose to (Batch, Channels, Time)
|
| 79 |
+
res = x
|
| 80 |
+
x = x.transpose(1, 2)
|
| 81 |
+
x = self.pointwise_in(x)
|
| 82 |
+
x = self.act_glu(x)
|
| 83 |
+
x = self.depthwise(x)
|
| 84 |
+
x = self.bn(x)
|
| 85 |
+
x = self.act_swish(x)
|
| 86 |
+
x = self.pointwise_out(x)
|
| 87 |
+
x = self.dropout(x)
|
| 88 |
+
x = x.transpose(1, 2)
|
| 89 |
+
return res + x
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class ExplicitMultiHeadAttention(nn.Module):
|
| 93 |
+
"""
|
| 94 |
+
Explicit Multi-Head Attention implemented using standard tensor operations
|
| 95 |
+
(MatMul, Scale, Softmax, MatMul) without proprietary PyTorch C++ kernels.
|
| 96 |
+
Guarantees 100% compatibility with ONNX export (opset 17) and CoreML conversion.
|
| 97 |
+
"""
|
| 98 |
+
def __init__(self, embed_dim: int, num_heads: int, dropout: float = 0.0):
|
| 99 |
+
super().__init__()
|
| 100 |
+
assert embed_dim % num_heads == 0, "embed_dim must be divisible by num_heads"
|
| 101 |
+
self.embed_dim = embed_dim
|
| 102 |
+
self.num_heads = num_heads
|
| 103 |
+
self.head_dim = embed_dim // num_heads
|
| 104 |
+
self.scale = 1.0 / math.sqrt(self.head_dim)
|
| 105 |
+
|
| 106 |
+
self.q_proj = nn.Linear(embed_dim, embed_dim)
|
| 107 |
+
self.k_proj = nn.Linear(embed_dim, embed_dim)
|
| 108 |
+
self.v_proj = nn.Linear(embed_dim, embed_dim)
|
| 109 |
+
self.out_proj = nn.Linear(embed_dim, embed_dim)
|
| 110 |
+
self.dropout = nn.Dropout(dropout)
|
| 111 |
+
|
| 112 |
+
def forward(
|
| 113 |
+
self,
|
| 114 |
+
q: torch.Tensor,
|
| 115 |
+
k: torch.Tensor,
|
| 116 |
+
v: torch.Tensor,
|
| 117 |
+
key_padding_mask: Optional[torch.Tensor] = None,
|
| 118 |
+
attn_bias: Optional[torch.Tensor] = None
|
| 119 |
+
) -> torch.Tensor:
|
| 120 |
+
b, t_q, _ = q.size()
|
| 121 |
+
_, t_k, _ = k.size()
|
| 122 |
+
|
| 123 |
+
# Fused QKV self-attention fast-path on MPS bfloat16
|
| 124 |
+
if (
|
| 125 |
+
q is k
|
| 126 |
+
and k is v
|
| 127 |
+
and q.device.type == "mps"
|
| 128 |
+
and not torch.jit.is_tracing()
|
| 129 |
+
and not torch.onnx.is_in_onnx_export()
|
| 130 |
+
):
|
| 131 |
+
qkv_w_view = getattr(self, "_qkv_w_view", None)
|
| 132 |
+
if qkv_w_view is not None and MetalFusedQKVLinearFunction is not None:
|
| 133 |
+
if torch.is_grad_enabled() and q.requires_grad:
|
| 134 |
+
qkv_lin = MetalFusedQKVLinearFunction.apply(
|
| 135 |
+
q, qkv_w_view, self._qkv_b_view, self._qkv_w_grad_view, self._qkv_b_grad_view
|
| 136 |
+
)
|
| 137 |
+
else:
|
| 138 |
+
qkv_lin = F.linear(q, qkv_w_view, self._qkv_b_view)
|
| 139 |
+
else:
|
| 140 |
+
w_qkv = torch.cat([self.q_proj.weight, self.k_proj.weight, self.v_proj.weight], dim=0)
|
| 141 |
+
b_qkv = torch.cat([self.q_proj.bias, self.k_proj.bias, self.v_proj.bias], dim=0)
|
| 142 |
+
qkv_lin = F.linear(q, w_qkv, b_qkv)
|
| 143 |
+
qkv = qkv_lin.view(b, t_q, 3, self.num_heads, self.head_dim).permute(2, 0, 3, 1, 4)
|
| 144 |
+
q_proj, k_proj, v_proj = qkv[0], qkv[1], qkv[2]
|
| 145 |
+
|
| 146 |
+
scores = torch.matmul(q_proj, k_proj.transpose(-2, -1)) * self.scale
|
| 147 |
+
if attn_bias is not None:
|
| 148 |
+
scores = scores + attn_bias
|
| 149 |
+
elif key_padding_mask is not None:
|
| 150 |
+
scores = scores.masked_fill(key_padding_mask[:, None, None, :], -10000.0)
|
| 151 |
+
|
| 152 |
+
probs = F.softmax(scores.float(), dim=-1).to(dtype=q.dtype)
|
| 153 |
+
if self.training and self.dropout.p > 0.0:
|
| 154 |
+
probs = F.dropout(probs, p=self.dropout.p, training=True)
|
| 155 |
+
context = torch.matmul(probs, v_proj).transpose(1, 2).reshape(b, t_q, self.embed_dim)
|
| 156 |
+
return self.out_proj(context)
|
| 157 |
+
|
| 158 |
+
q_proj = self.q_proj(q).view(b, t_q, self.num_heads, self.head_dim).transpose(1, 2)
|
| 159 |
+
k_proj = self.k_proj(k).view(b, t_k, self.num_heads, self.head_dim).transpose(1, 2)
|
| 160 |
+
v_proj = self.v_proj(v).view(b, t_k, self.num_heads, self.head_dim).transpose(1, 2)
|
| 161 |
+
|
| 162 |
+
# Scaled dot-product attention using MPS-native fused SDPA kernel
|
| 163 |
+
attn_mask = None
|
| 164 |
+
if attn_bias is not None:
|
| 165 |
+
attn_mask = attn_bias
|
| 166 |
+
elif key_padding_mask is not None:
|
| 167 |
+
# key_padding_mask: True for pad positions (b, t_k)
|
| 168 |
+
# In SDPA boolean mask: True means attend (valid), False means mask out (pad)
|
| 169 |
+
attn_mask = (~key_padding_mask).unsqueeze(1).unsqueeze(2)
|
| 170 |
+
|
| 171 |
+
dropout_p = self.dropout.p if self.training else 0.0
|
| 172 |
+
context = F.scaled_dot_product_attention(
|
| 173 |
+
q_proj, k_proj, v_proj,
|
| 174 |
+
attn_mask=attn_mask,
|
| 175 |
+
dropout_p=dropout_p
|
| 176 |
+
) # (b, heads, t_q, head_dim)
|
| 177 |
+
context = context.transpose(1, 2).contiguous().view(b, t_q, self.embed_dim)
|
| 178 |
+
return self.out_proj(context)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
class ConformerBlock(nn.Module):
|
| 182 |
+
"""
|
| 183 |
+
A lightweight Conformer block combining:
|
| 184 |
+
- Pre-norm Multi-Head Self-Attention (MHSA)
|
| 185 |
+
- Depthwise Convolution for local speech features
|
| 186 |
+
- Feed-Forward Network (FFN) with half-step Macaron-style scaling
|
| 187 |
+
"""
|
| 188 |
+
def __init__(
|
| 189 |
+
self,
|
| 190 |
+
d_model: int = 256,
|
| 191 |
+
nhead: int = 4,
|
| 192 |
+
dim_feedforward: int = 1024,
|
| 193 |
+
dropout: float = 0.1,
|
| 194 |
+
conv_kernel_size: int = 15
|
| 195 |
+
):
|
| 196 |
+
super().__init__()
|
| 197 |
+
# 1. First FFN (Macaron style)
|
| 198 |
+
self.ffn1_norm = nn.LayerNorm(d_model)
|
| 199 |
+
self.ffn1 = nn.Sequential(
|
| 200 |
+
nn.Linear(d_model, dim_feedforward),
|
| 201 |
+
nn.GELU(),
|
| 202 |
+
nn.Dropout(dropout),
|
| 203 |
+
nn.Linear(dim_feedforward, d_model),
|
| 204 |
+
nn.Dropout(dropout)
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
# 2. Multi-Head Self-Attention (Explicit ops for mobile deployment)
|
| 208 |
+
self.attn_norm = nn.LayerNorm(d_model)
|
| 209 |
+
self.self_attn = ExplicitMultiHeadAttention(
|
| 210 |
+
embed_dim=d_model,
|
| 211 |
+
num_heads=nhead,
|
| 212 |
+
dropout=dropout
|
| 213 |
+
)
|
| 214 |
+
self.attn_dropout = nn.Dropout(dropout)
|
| 215 |
+
|
| 216 |
+
# 3. Convolution Module
|
| 217 |
+
self.conv_norm = nn.LayerNorm(d_model)
|
| 218 |
+
self.conv_module = DepthwiseConv1d(
|
| 219 |
+
channels=d_model,
|
| 220 |
+
kernel_size=conv_kernel_size,
|
| 221 |
+
dropout=dropout
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
# 4. Second FFN
|
| 225 |
+
self.ffn2_norm = nn.LayerNorm(d_model)
|
| 226 |
+
self.ffn2 = nn.Sequential(
|
| 227 |
+
nn.Linear(d_model, dim_feedforward),
|
| 228 |
+
nn.GELU(),
|
| 229 |
+
nn.Dropout(dropout),
|
| 230 |
+
nn.Linear(dim_feedforward, d_model),
|
| 231 |
+
nn.Dropout(dropout)
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
self.final_norm = nn.LayerNorm(d_model)
|
| 235 |
+
|
| 236 |
+
def forward(
|
| 237 |
+
self,
|
| 238 |
+
x: torch.Tensor,
|
| 239 |
+
key_padding_mask: Optional[torch.Tensor] = None,
|
| 240 |
+
attn_bias: Optional[torch.Tensor] = None
|
| 241 |
+
) -> torch.Tensor:
|
| 242 |
+
# FFN 1 with 0.5 residual scale
|
| 243 |
+
x = x + 0.5 * self.ffn1(self.ffn1_norm(x))
|
| 244 |
+
|
| 245 |
+
# Multi-Head Attention
|
| 246 |
+
normed = self.attn_norm(x)
|
| 247 |
+
attn_out = self.self_attn(
|
| 248 |
+
normed, normed, normed,
|
| 249 |
+
key_padding_mask=key_padding_mask,
|
| 250 |
+
attn_bias=attn_bias
|
| 251 |
+
)
|
| 252 |
+
x = x + self.attn_dropout(attn_out)
|
| 253 |
+
|
| 254 |
+
# Convolution Module
|
| 255 |
+
x = x + self.conv_module(self.conv_norm(x))
|
| 256 |
+
|
| 257 |
+
# FFN 2 with 0.5 residual scale
|
| 258 |
+
x = x + 0.5 * self.ffn2(self.ffn2_norm(x))
|
| 259 |
+
|
| 260 |
+
return self.final_norm(x)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
# ==============================================================================
|
| 264 |
+
# 2. Audio-to-Vector Reader Module
|
| 265 |
+
# ==============================================================================
|
| 266 |
+
|
| 267 |
+
class AudioToVectorReader(nn.Module):
|
| 268 |
+
"""
|
| 269 |
+
Reader v2 Conformer Architecture (~21.5M parameters).
|
| 270 |
+
Input: Audio Encoder features of shape (Batch, Time, input_dim=256)
|
| 271 |
+
Internal: Conformer latent space (d_model=384, nhead=6, num_layers=10, dim_feedforward=1536)
|
| 272 |
+
Output: Normalized vectors of shape (Batch, Time, output_dim=256) aligned with EmbeddingGemma-2 text space.
|
| 273 |
+
"""
|
| 274 |
+
def __init__(
|
| 275 |
+
self,
|
| 276 |
+
input_dim: int = 256,
|
| 277 |
+
output_dim: int = 256,
|
| 278 |
+
d_model: int = 384,
|
| 279 |
+
nhead: int = 6,
|
| 280 |
+
num_layers: int = 10,
|
| 281 |
+
dim_feedforward: int = 1536,
|
| 282 |
+
dropout: float = 0.1,
|
| 283 |
+
conv_kernel_size: int = 15
|
| 284 |
+
):
|
| 285 |
+
super().__init__()
|
| 286 |
+
self.input_dim = input_dim
|
| 287 |
+
self.output_dim = output_dim
|
| 288 |
+
self.d_model = d_model
|
| 289 |
+
|
| 290 |
+
self.input_proj = nn.Linear(input_dim, d_model)
|
| 291 |
+
self.pos_emb = PositionalEncoding(d_model, max_len=8192)
|
| 292 |
+
|
| 293 |
+
self.layers = nn.ModuleList([
|
| 294 |
+
ConformerBlock(
|
| 295 |
+
d_model=d_model,
|
| 296 |
+
nhead=nhead,
|
| 297 |
+
dim_feedforward=dim_feedforward,
|
| 298 |
+
dropout=dropout,
|
| 299 |
+
conv_kernel_size=conv_kernel_size
|
| 300 |
+
)
|
| 301 |
+
for _ in range(num_layers)
|
| 302 |
+
])
|
| 303 |
+
|
| 304 |
+
self.output_proj = nn.Sequential(
|
| 305 |
+
nn.Linear(d_model, output_dim),
|
| 306 |
+
nn.LayerNorm(output_dim)
|
| 307 |
+
)
|
| 308 |
+
# Small std=0.02 normal initialization to enable smooth early gradient flow
|
| 309 |
+
nn.init.normal_(self.output_proj[0].weight, std=0.02)
|
| 310 |
+
nn.init.zeros_(self.output_proj[0].bias)
|
| 311 |
+
|
| 312 |
+
# Learnable global residual scale to balance acoustic adaptation and pretrained alignment
|
| 313 |
+
self.res_scale = nn.Parameter(torch.tensor(0.5, dtype=torch.float32))
|
| 314 |
+
|
| 315 |
+
def forward(
|
| 316 |
+
self,
|
| 317 |
+
x: torch.Tensor,
|
| 318 |
+
key_padding_mask: Optional[torch.Tensor] = None
|
| 319 |
+
) -> torch.Tensor:
|
| 320 |
+
"""
|
| 321 |
+
Args:
|
| 322 |
+
x: Tensor of shape (Batch, Time, input_dim)
|
| 323 |
+
key_padding_mask: Optional bool tensor (Batch, Time) where True denotes padded positions
|
| 324 |
+
Returns:
|
| 325 |
+
L2-normalized vectors (Batch, Time, output_dim)
|
| 326 |
+
"""
|
| 327 |
+
res = x
|
| 328 |
+
h = self.input_proj(x)
|
| 329 |
+
h = self.pos_emb(h)
|
| 330 |
+
|
| 331 |
+
# Precompute additive attention mask once across all Conformer layers
|
| 332 |
+
attn_bias: Optional[torch.Tensor] = None
|
| 333 |
+
if key_padding_mask is not None:
|
| 334 |
+
attn_bias = key_padding_mask[:, None, None, :].to(dtype=h.dtype) * -10000.0
|
| 335 |
+
|
| 336 |
+
for layer in self.layers:
|
| 337 |
+
h = layer(h, attn_bias=attn_bias)
|
| 338 |
+
|
| 339 |
+
out = self.output_proj(h)
|
| 340 |
+
|
| 341 |
+
# Fused Metal Residual Scale + Mask + FP32-Accumulated L2 Normalization fast-path
|
| 342 |
+
if (
|
| 343 |
+
out.device.type == "mps"
|
| 344 |
+
and out.dtype == torch.bfloat16
|
| 345 |
+
and res.dtype == torch.bfloat16
|
| 346 |
+
and res.shape[-1] == out.shape[-1]
|
| 347 |
+
and out.shape[-1] == 256
|
| 348 |
+
and key_padding_mask is not None
|
| 349 |
+
and MetalFusedResL2NormFunction is not None
|
| 350 |
+
and not torch.jit.is_tracing()
|
| 351 |
+
and not torch.onnx.is_in_onnx_export()
|
| 352 |
+
):
|
| 353 |
+
return MetalFusedResL2NormFunction.apply(out, res, self.res_scale, key_padding_mask)
|
| 354 |
+
|
| 355 |
+
# Global residual connection with learnable scale parameter
|
| 356 |
+
if res.shape[-1] == out.shape[-1]:
|
| 357 |
+
combined = self.res_scale * res + out
|
| 358 |
+
else:
|
| 359 |
+
combined = out
|
| 360 |
+
|
| 361 |
+
# Zero out padding positions before normalization if key_padding_mask provided
|
| 362 |
+
if key_padding_mask is not None:
|
| 363 |
+
combined = combined.masked_fill(key_padding_mask.unsqueeze(-1), 0.0)
|
| 364 |
+
|
| 365 |
+
# Use FP32 accumulation + safe eps=1e-5 to guarantee zero underflow/NaN in bfloat16
|
| 366 |
+
normed = F.normalize(combined.float(), p=2, dim=-1, eps=1e-5).to(dtype=x.dtype)
|
| 367 |
+
if key_padding_mask is not None:
|
| 368 |
+
normed = normed.masked_fill(key_padding_mask.unsqueeze(-1), 0.0)
|
| 369 |
+
return normed
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
class PositionalEncoding(nn.Module):
|
| 373 |
+
"""Sinusoidal Positional Encoding compatible with dynamic sequence lengths."""
|
| 374 |
+
def __init__(self, d_model: int, max_len: int = 8192):
|
| 375 |
+
super().__init__()
|
| 376 |
+
pe = torch.zeros(max_len, d_model)
|
| 377 |
+
position = torch.arange(0, max_len, dtype=torch.float32).unsqueeze(1)
|
| 378 |
+
div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
|
| 379 |
+
pe[:, 0::2] = torch.sin(position * div_term)
|
| 380 |
+
pe[:, 1::2] = torch.cos(position * div_term)
|
| 381 |
+
self.register_buffer("pe", pe.unsqueeze(0), persistent=False)
|
| 382 |
+
|
| 383 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 384 |
+
seq_len = x.size(1)
|
| 385 |
+
return x + self.pe[:, :seq_len].to(dtype=x.dtype, device=x.device)
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
# ==============================================================================
|
| 389 |
+
# 3. Cosine Similarity Matching & Blank Logit Projection Head
|
| 390 |
+
# ==============================================================================
|
| 391 |
+
|
| 392 |
+
class CosineCTCHead(nn.Module):
|
| 393 |
+
"""
|
| 394 |
+
Unified Cosine Similarity Matching & Blank Head:
|
| 395 |
+
- Learns a unit vector in the spherical space for <blank>: self.blank_vec
|
| 396 |
+
- Combines [norm_blank, vector_bank] -> all_vectors: (V + 1, embedding_dim)
|
| 397 |
+
- Computes single matrix multiplication: (embeddings @ all_vectors.T) * logit_scale
|
| 398 |
+
- Applies learnable scalar bias offset to <blank> (index 0) and per-token vocab_bias (index 1..V)
|
| 399 |
+
- Supports trainable vector bank mode for end-to-end representation adaptation into acoustic space
|
| 400 |
+
"""
|
| 401 |
+
def __init__(
|
| 402 |
+
self,
|
| 403 |
+
embedding_dim: int = 256,
|
| 404 |
+
init_temperature: float = 0.05,
|
| 405 |
+
vector_bank: Optional[torch.Tensor] = None,
|
| 406 |
+
trainable_vector_bank: bool = True
|
| 407 |
+
):
|
| 408 |
+
super().__init__()
|
| 409 |
+
self.embedding_dim = embedding_dim
|
| 410 |
+
self.trainable_vector_bank = trainable_vector_bank
|
| 411 |
+
# logit_scale initialized to ln(1 / init_temperature), safely bounded in [-5.0, 5.2983]
|
| 412 |
+
init_logit_scale = math.log(1.0 / max(init_temperature, 1e-4))
|
| 413 |
+
init_logit_scale = max(min(init_logit_scale, 5.2983), -5.0)
|
| 414 |
+
self.logit_scale = nn.Parameter(torch.tensor(init_logit_scale, dtype=torch.float32))
|
| 415 |
+
|
| 416 |
+
# Learnable parameter vector in R^embedding_dim for <blank> token (Index 0)
|
| 417 |
+
self.blank_vec = nn.Parameter(torch.randn(1, embedding_dim))
|
| 418 |
+
nn.init.normal_(self.blank_vec, std=0.02)
|
| 419 |
+
|
| 420 |
+
# Learnable scalar bias for <blank> token (Index 0)
|
| 421 |
+
self.blank_bias = nn.Parameter(torch.zeros(1, dtype=torch.float32))
|
| 422 |
+
|
| 423 |
+
# Vector bank buffer or parameter (V, embedding_dim) & token prior bias
|
| 424 |
+
if vector_bank is not None:
|
| 425 |
+
vb_f32 = vector_bank.to(torch.float32)
|
| 426 |
+
norm_vb = F.normalize(vb_f32, p=2, dim=-1)
|
| 427 |
+
if trainable_vector_bank:
|
| 428 |
+
self.vector_bank = nn.Parameter(norm_vb)
|
| 429 |
+
else:
|
| 430 |
+
self.register_buffer("vector_bank", norm_vb, persistent=False)
|
| 431 |
+
self.vocab_bias = nn.Parameter(torch.zeros(norm_vb.size(0), dtype=torch.float32))
|
| 432 |
+
else:
|
| 433 |
+
self.register_buffer("vector_bank", None, persistent=False)
|
| 434 |
+
self.vocab_bias = nn.Parameter(torch.zeros(0, dtype=torch.float32))
|
| 435 |
+
|
| 436 |
+
def set_vector_bank(self, vector_bank: torch.Tensor):
|
| 437 |
+
"""Sets or updates the target vector bank (V, embedding_dim)."""
|
| 438 |
+
vb_f32 = vector_bank.to(torch.float32)
|
| 439 |
+
norm_vb = F.normalize(vb_f32, p=2, dim=-1)
|
| 440 |
+
if self.trainable_vector_bank:
|
| 441 |
+
self.vector_bank = nn.Parameter(norm_vb)
|
| 442 |
+
else:
|
| 443 |
+
self.register_buffer("vector_bank", norm_vb, persistent=False)
|
| 444 |
+
self.vocab_bias = nn.Parameter(torch.zeros(norm_vb.size(0), dtype=torch.float32))
|
| 445 |
+
|
| 446 |
+
def forward(
|
| 447 |
+
self,
|
| 448 |
+
embeddings: torch.Tensor,
|
| 449 |
+
external_vector_bank: Optional[torch.Tensor] = None
|
| 450 |
+
) -> torch.Tensor:
|
| 451 |
+
"""
|
| 452 |
+
Args:
|
| 453 |
+
embeddings: Normalized Reader output (Batch, Time, embedding_dim)
|
| 454 |
+
external_vector_bank: Optional runtime vector bank (V, embedding_dim)
|
| 455 |
+
Returns:
|
| 456 |
+
Logits of shape (Batch, Time, V + 1), where index 0 is <blank>
|
| 457 |
+
"""
|
| 458 |
+
vb = external_vector_bank if external_vector_bank is not None else self.vector_bank
|
| 459 |
+
if vb is None:
|
| 460 |
+
raise ValueError("Vector bank must be provided either in constructor, set_vector_bank(), or as an argument.")
|
| 461 |
+
|
| 462 |
+
# On MPS, execute the large (B*T, 256) x (256, V+1) projection strictly in bfloat16
|
| 463 |
+
# (with FP32 L2-normalization and clamped scale) to prevent MPS float32/float16 NaN bugs.
|
| 464 |
+
compute_dtype = torch.bfloat16 if embeddings.device.type == "mps" else torch.float32
|
| 465 |
+
|
| 466 |
+
emb_f32 = embeddings.to(dtype=torch.float32)
|
| 467 |
+
norm_blank = F.normalize(self.blank_vec.to(device=embeddings.device, dtype=torch.float32), p=2, dim=-1, eps=1e-5)
|
| 468 |
+
|
| 469 |
+
if external_vector_bank is not None or self.trainable_vector_bank:
|
| 470 |
+
vb_matched = vb.to(device=embeddings.device, dtype=torch.float32)
|
| 471 |
+
norm_vb = F.normalize(vb_matched, p=2, dim=-1, eps=1e-5)
|
| 472 |
+
else:
|
| 473 |
+
# Frozen vector_bank is already float32 and L2-normalized in __init__ / set_vector_bank
|
| 474 |
+
norm_vb = vb if (vb.device == embeddings.device and vb.dtype == torch.float32) else vb.to(device=embeddings.device, dtype=torch.float32)
|
| 475 |
+
|
| 476 |
+
all_vectors = torch.cat([norm_blank, norm_vb], dim=0).to(dtype=compute_dtype) # (V + 1, embedding_dim)
|
| 477 |
+
|
| 478 |
+
# Safely clamp logit_scale parameter directly BEFORE exp to prevent math overflow
|
| 479 |
+
# ln(200) = ~5.2983. Clamping between -5.0 and 5.2983 bounds scale in [0.0067, 200.0]
|
| 480 |
+
clamped_scale_log = torch.clamp(self.logit_scale, min=-5.0, max=5.2983)
|
| 481 |
+
scale = torch.exp(clamped_scale_log)
|
| 482 |
+
scaled_emb = (emb_f32 * scale).to(dtype=compute_dtype)
|
| 483 |
+
|
| 484 |
+
# Fused linear projection + per-token prior bias [blank_bias, vocab_bias]
|
| 485 |
+
blank_b = self.blank_bias.to(device=embeddings.device, dtype=compute_dtype)
|
| 486 |
+
if self.vocab_bias is not None and self.vocab_bias.size(0) == norm_vb.size(0):
|
| 487 |
+
vocab_b = self.vocab_bias.to(device=embeddings.device, dtype=compute_dtype)
|
| 488 |
+
else:
|
| 489 |
+
vocab_b = torch.zeros(norm_vb.size(0), device=embeddings.device, dtype=compute_dtype)
|
| 490 |
+
bias = torch.cat([blank_b, vocab_b], dim=0)
|
| 491 |
+
|
| 492 |
+
return F.linear(scaled_emb, all_vectors, bias)
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
# ==============================================================================
|
| 496 |
+
# 4. SpecAugment for 256-dim Conformer Features
|
| 497 |
+
# ==============================================================================
|
| 498 |
+
|
| 499 |
+
class SpecAugment1D(nn.Module):
|
| 500 |
+
"""
|
| 501 |
+
Time & Feature Masking for 256-dim feature sequences:
|
| 502 |
+
- Time Masking: 2 masks, random length between 5 and 15 frames
|
| 503 |
+
- Feature Masking: 2 masks, random length between 16 and 32 dimensions
|
| 504 |
+
Applied strictly during model.train() mode.
|
| 505 |
+
"""
|
| 506 |
+
def __init__(
|
| 507 |
+
self,
|
| 508 |
+
time_mask_min: int = 5,
|
| 509 |
+
time_mask_max: int = 15,
|
| 510 |
+
num_time_masks: int = 2,
|
| 511 |
+
freq_mask_min: int = 16,
|
| 512 |
+
freq_mask_max: int = 32,
|
| 513 |
+
num_freq_masks: int = 2
|
| 514 |
+
):
|
| 515 |
+
super().__init__()
|
| 516 |
+
self.time_mask_min = time_mask_min
|
| 517 |
+
self.time_mask_max = time_mask_max
|
| 518 |
+
self.num_time_masks = num_time_masks
|
| 519 |
+
self.freq_mask_min = freq_mask_min
|
| 520 |
+
self.freq_mask_max = freq_mask_max
|
| 521 |
+
self.num_freq_masks = num_freq_masks
|
| 522 |
+
|
| 523 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 524 |
+
if not self.training:
|
| 525 |
+
return x
|
| 526 |
+
|
| 527 |
+
B, T, D = x.shape
|
| 528 |
+
x_aug = x.clone()
|
| 529 |
+
|
| 530 |
+
# Time masking (2 times, 5~15 frames)
|
| 531 |
+
for _ in range(self.num_time_masks):
|
| 532 |
+
t_len = random.randint(self.time_mask_min, min(self.time_mask_max, max(T - 1, self.time_mask_min)))
|
| 533 |
+
t_start = random.randint(0, max(0, T - t_len))
|
| 534 |
+
x_aug[:, t_start : t_start + t_len, :] = 0.0
|
| 535 |
+
|
| 536 |
+
# Feature masking (2 times, 16~32 dims)
|
| 537 |
+
for _ in range(self.num_freq_masks):
|
| 538 |
+
f_len = random.randint(self.freq_mask_min, min(self.freq_mask_max, max(D - 1, self.freq_mask_min)))
|
| 539 |
+
f_start = random.randint(0, max(0, D - f_len))
|
| 540 |
+
x_aug[:, :, f_start : f_start + f_len] = 0.0
|
| 541 |
+
|
| 542 |
+
return x_aug
|
| 543 |
+
|
| 544 |
+
|
| 545 |
+
# ==============================================================================
|
| 546 |
+
# 5. Complete Trainable Reader System
|
| 547 |
+
# ==============================================================================
|
| 548 |
+
|
| 549 |
+
class EmbeddingGemmaCTCReader(nn.Module):
|
| 550 |
+
"""
|
| 551 |
+
Combined Module for training:
|
| 552 |
+
AudioToVectorReader + CosineCTCHead with SpecAugment and Trainable Vector Bank options.
|
| 553 |
+
"""
|
| 554 |
+
def __init__(
|
| 555 |
+
self,
|
| 556 |
+
input_dim: int = 256,
|
| 557 |
+
output_dim: int = 256,
|
| 558 |
+
d_model: int = 384,
|
| 559 |
+
nhead: int = 6,
|
| 560 |
+
num_layers: int = 10,
|
| 561 |
+
dim_feedforward: int = 1536,
|
| 562 |
+
dropout: float = 0.1,
|
| 563 |
+
conv_kernel_size: int = 15,
|
| 564 |
+
init_temperature: float = 0.05,
|
| 565 |
+
vector_bank: Optional[torch.Tensor] = None,
|
| 566 |
+
use_spec_augment: bool = True,
|
| 567 |
+
trainable_vector_bank: bool = True
|
| 568 |
+
):
|
| 569 |
+
super().__init__()
|
| 570 |
+
self.reader = AudioToVectorReader(
|
| 571 |
+
input_dim=input_dim,
|
| 572 |
+
output_dim=output_dim,
|
| 573 |
+
d_model=d_model,
|
| 574 |
+
nhead=nhead,
|
| 575 |
+
num_layers=num_layers,
|
| 576 |
+
dim_feedforward=dim_feedforward,
|
| 577 |
+
dropout=dropout,
|
| 578 |
+
conv_kernel_size=conv_kernel_size
|
| 579 |
+
)
|
| 580 |
+
self.ctc_head = CosineCTCHead(
|
| 581 |
+
embedding_dim=output_dim,
|
| 582 |
+
init_temperature=init_temperature,
|
| 583 |
+
vector_bank=vector_bank,
|
| 584 |
+
trainable_vector_bank=trainable_vector_bank
|
| 585 |
+
)
|
| 586 |
+
self.spec_aug = SpecAugment1D(
|
| 587 |
+
time_mask_min=5,
|
| 588 |
+
time_mask_max=15,
|
| 589 |
+
num_time_masks=2,
|
| 590 |
+
freq_mask_min=16,
|
| 591 |
+
freq_mask_max=32,
|
| 592 |
+
num_freq_masks=2
|
| 593 |
+
) if use_spec_augment else None
|
| 594 |
+
|
| 595 |
+
def enable_mps_bfloat16_fastpath(self) -> None:
|
| 596 |
+
"""
|
| 597 |
+
Configures Reader Conformer layers in native bfloat16 while preserving float32
|
| 598 |
+
BatchNorm1d parameters/stats, res_scale, and CosineCTCHead parameters.
|
| 599 |
+
Eliminates 200+ per-step autocast weight-casting kernels on Apple Silicon MPS.
|
| 600 |
+
"""
|
| 601 |
+
self.reader.to(dtype=torch.bfloat16)
|
| 602 |
+
for layer in self.reader.layers:
|
| 603 |
+
layer.conv_module.bn.to(dtype=torch.float32)
|
| 604 |
+
self.reader.res_scale.data = self.reader.res_scale.data.to(dtype=torch.float32)
|
| 605 |
+
self.ctc_head.to(dtype=torch.float32)
|
| 606 |
+
|
| 607 |
+
def forward_reader(
|
| 608 |
+
self,
|
| 609 |
+
features: torch.Tensor,
|
| 610 |
+
key_padding_mask: Optional[torch.Tensor] = None
|
| 611 |
+
) -> torch.Tensor:
|
| 612 |
+
"""Runs SpecAugment (if training) + AudioToVectorReader without CosineCTCHead."""
|
| 613 |
+
if self.training and self.spec_aug is not None:
|
| 614 |
+
features = self.spec_aug(features)
|
| 615 |
+
return self.reader(features, key_padding_mask=key_padding_mask)
|
| 616 |
+
|
| 617 |
+
def forward(
|
| 618 |
+
self,
|
| 619 |
+
features: torch.Tensor,
|
| 620 |
+
key_padding_mask: Optional[torch.Tensor] = None,
|
| 621 |
+
external_vector_bank: Optional[torch.Tensor] = None
|
| 622 |
+
) -> torch.Tensor:
|
| 623 |
+
"""
|
| 624 |
+
Args:
|
| 625 |
+
features: Cached audio features (Batch, Time, 256)
|
| 626 |
+
key_padding_mask: Padding mask (Batch, Time)
|
| 627 |
+
external_vector_bank: Optional custom vector bank (V, 256)
|
| 628 |
+
Returns:
|
| 629 |
+
Logits: (Batch, Time, V + 1)
|
| 630 |
+
"""
|
| 631 |
+
if self.training and self.spec_aug is not None:
|
| 632 |
+
features = self.spec_aug(features)
|
| 633 |
+
|
| 634 |
+
embeddings = self.reader(features, key_padding_mask=key_padding_mask)
|
| 635 |
+
logits = self.ctc_head(embeddings, external_vector_bank=external_vector_bank)
|
| 636 |
+
return logits
|
| 637 |
+
|
| 638 |
+
def extract_reader_embeddings(
|
| 639 |
+
self,
|
| 640 |
+
features: torch.Tensor,
|
| 641 |
+
key_padding_mask: Optional[torch.Tensor] = None
|
| 642 |
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 643 |
+
"""
|
| 644 |
+
Outputs normalized embeddings and blank logit separately
|
| 645 |
+
(Used for Mobile OTA / Plug-and-Play Dictionary mode).
|
| 646 |
+
"""
|
| 647 |
+
embeddings = self.reader(features, key_padding_mask=key_padding_mask)
|
| 648 |
+
norm_blank = F.normalize(self.ctc_head.blank_vec.to(device=embeddings.device), p=2, dim=-1)
|
| 649 |
+
scale = torch.clamp(self.ctc_head.logit_scale.exp(), max=200.0)
|
| 650 |
+
blank_logit = torch.matmul(embeddings, norm_blank.t()) * scale + self.ctc_head.blank_bias
|
| 651 |
+
return embeddings, blank_logit
|
| 652 |
+
|
| 653 |
+
def count_parameters(self) -> Dict[str, int]:
|
| 654 |
+
"""Returns train parameter counts."""
|
| 655 |
+
total = sum(p.numel() for p in self.parameters())
|
| 656 |
+
trainable = sum(p.numel() for p in self.parameters() if p.requires_grad)
|
| 657 |
+
reader_params = sum(p.numel() for p in self.reader.parameters())
|
| 658 |
+
head_params = sum(p.numel() for p in self.ctc_head.parameters())
|
| 659 |
+
return {
|
| 660 |
+
"total_params": total,
|
| 661 |
+
"trainable_params": trainable,
|
| 662 |
+
"reader_params": reader_params,
|
| 663 |
+
"ctc_head_params": head_params
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
|
| 667 |
+
# ==============================================================================
|
| 668 |
+
# 5. Native Fallback Audio Encoder (Matches EmbeddingGemma-2 Audio Front-End)
|
| 669 |
+
# ==============================================================================
|
| 670 |
+
|
| 671 |
+
class StandardTransformerBlock(nn.Module):
|
| 672 |
+
"""Clean, standard Transformer block without proprietary fastpath C++ kernels."""
|
| 673 |
+
def __init__(self, d_model: int = 1024, nhead: int = 8, dim_feedforward: int = 2048, dropout: float = 0.0):
|
| 674 |
+
super().__init__()
|
| 675 |
+
self.norm1 = nn.LayerNorm(d_model)
|
| 676 |
+
self.self_attn = ExplicitMultiHeadAttention(embed_dim=d_model, num_heads=nhead, dropout=dropout)
|
| 677 |
+
self.norm2 = nn.LayerNorm(d_model)
|
| 678 |
+
self.ffn = nn.Sequential(
|
| 679 |
+
nn.Linear(d_model, dim_feedforward),
|
| 680 |
+
nn.GELU(),
|
| 681 |
+
nn.Dropout(dropout),
|
| 682 |
+
nn.Linear(dim_feedforward, d_model),
|
| 683 |
+
nn.Dropout(dropout)
|
| 684 |
+
)
|
| 685 |
+
|
| 686 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 687 |
+
nx = self.norm1(x)
|
| 688 |
+
attn_out = self.self_attn(nx, nx, nx)
|
| 689 |
+
x = x + attn_out
|
| 690 |
+
x = x + self.ffn(self.norm2(x))
|
| 691 |
+
return x
|
| 692 |
+
|
| 693 |
+
|
| 694 |
+
class RealEmbeddingGemma2AudioEncoder(nn.Module):
|
| 695 |
+
"""
|
| 696 |
+
Official 305M Audio Encoder from google/embeddinggemma-2:
|
| 697 |
+
- audio_tower: 304.8M Gemma 4 Audio Conformer Encoder
|
| 698 |
+
- embed_audio: 0.8M Multimodal Projector
|
| 699 |
+
- Matryoshka Slicing to 256 dimensions + L2 Normalization
|
| 700 |
+
Strictly uses real pretrained weights; raises RuntimeError on failure.
|
| 701 |
+
"""
|
| 702 |
+
def __init__(self, audio_tower: nn.Module, embed_audio: nn.Module):
|
| 703 |
+
super().__init__()
|
| 704 |
+
self.audio_tower = audio_tower
|
| 705 |
+
self.embed_audio = embed_audio
|
| 706 |
+
|
| 707 |
+
@classmethod
|
| 708 |
+
def from_pretrained(
|
| 709 |
+
cls,
|
| 710 |
+
model_name: str = "google/embeddinggemma-2",
|
| 711 |
+
dtype: torch.dtype = torch.bfloat16
|
| 712 |
+
) -> "RealEmbeddingGemma2AudioEncoder":
|
| 713 |
+
"""Loads and freezes the genuine 300M Audio Encoder from Hugging Face checkpoint."""
|
| 714 |
+
try:
|
| 715 |
+
from transformers import AutoModel
|
| 716 |
+
full_model = AutoModel.from_pretrained(model_name, dtype=dtype)
|
| 717 |
+
audio_tower = full_model.audio_tower
|
| 718 |
+
embed_audio = full_model.embed_audio
|
| 719 |
+
|
| 720 |
+
# Freeze all parameters
|
| 721 |
+
for p in audio_tower.parameters():
|
| 722 |
+
p.requires_grad = False
|
| 723 |
+
for p in embed_audio.parameters():
|
| 724 |
+
p.requires_grad = False
|
| 725 |
+
|
| 726 |
+
encoder = cls(audio_tower=audio_tower, embed_audio=embed_audio)
|
| 727 |
+
encoder.eval()
|
| 728 |
+
return encoder
|
| 729 |
+
except Exception as e:
|
| 730 |
+
raise RuntimeError(
|
| 731 |
+
f"[FATAL ERROR] Failed to load genuine '{model_name}' Audio Encoder: {e}\n"
|
| 732 |
+
f"Make sure transformers is up-to-date: pip install git+https://github.com/huggingface/transformers.git"
|
| 733 |
+
)
|
| 734 |
+
|
| 735 |
+
def forward(self, input_features: torch.Tensor) -> torch.Tensor:
|
| 736 |
+
"""
|
| 737 |
+
Args:
|
| 738 |
+
input_features: (Batch, Time_Frames, 128) 128-bin log-mel filterbanks
|
| 739 |
+
Returns:
|
| 740 |
+
(Batch, Time_Tokens, 256) MRL-sliced, L2-normalized representations
|
| 741 |
+
"""
|
| 742 |
+
tower_out = self.audio_tower(input_features, attention_mask=None)
|
| 743 |
+
audio_hs = tower_out.last_hidden_state # (B, T, 1536)
|
| 744 |
+
audio_proj = self.embed_audio(audio_hs) # (B, T, 512)
|
| 745 |
+
mrl_256 = audio_proj[:, :, :256]
|
| 746 |
+
return F.normalize(mrl_256.float(), p=2, dim=-1)
|
| 747 |
+
|
| 748 |
+
|
| 749 |
+
# Quick Self-Test when executed directly
|
| 750 |
+
if __name__ == "__main__":
|
| 751 |
+
print("[Testing model.py - Reader v2 Conformer]")
|
| 752 |
+
batch_size = 2
|
| 753 |
+
time_steps = 150
|
| 754 |
+
input_dim = 256
|
| 755 |
+
output_dim = 256
|
| 756 |
+
vocab_size = 5999 # Sliced vocab size (5,999 non-blank tokens -> 6,000 total with <blank>)
|
| 757 |
+
|
| 758 |
+
dummy_features = torch.randn(batch_size, time_steps, input_dim)
|
| 759 |
+
dummy_vector_bank = torch.randn(vocab_size, output_dim)
|
| 760 |
+
|
| 761 |
+
model = EmbeddingGemmaCTCReader(
|
| 762 |
+
input_dim=input_dim,
|
| 763 |
+
output_dim=output_dim,
|
| 764 |
+
d_model=384,
|
| 765 |
+
nhead=6,
|
| 766 |
+
num_layers=10,
|
| 767 |
+
dim_feedforward=1536,
|
| 768 |
+
conv_kernel_size=15,
|
| 769 |
+
init_temperature=0.05,
|
| 770 |
+
vector_bank=dummy_vector_bank,
|
| 771 |
+
trainable_vector_bank=True
|
| 772 |
+
)
|
| 773 |
+
|
| 774 |
+
stats = model.count_parameters()
|
| 775 |
+
print("Parameter Stats:")
|
| 776 |
+
for k, v in stats.items():
|
| 777 |
+
print(f" {k}: {v:,} ({v / 1e6:.2f}M)")
|
| 778 |
+
|
| 779 |
+
logits = model(dummy_features)
|
| 780 |
+
print(f"Input features shape: {dummy_features.shape}")
|
| 781 |
+
print(f"Logits output shape: {logits.shape} (Expected: ({batch_size}, {time_steps}, {vocab_size + 1}))")
|
| 782 |
+
assert logits.shape == (batch_size, time_steps, vocab_size + 1)
|
| 783 |
+
print("Model test PASSED successfully!")
|
src/step2_extract_features.py
ADDED
|
@@ -0,0 +1,1476 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
src/step2_extract_features.py
|
| 3 |
+
|
| 4 |
+
Step 2: Pre-extract and Cache Audio Features from Genuine Pretrained 300M Audio Encoder
|
| 5 |
+
Loads the official google/embeddinggemma-2 audio_tower (304.8M) + embed_audio (0.8M)
|
| 6 |
+
+ language_model (271.0M) in torch.bfloat16 and freezes all parameters.
|
| 7 |
+
Includes Lecture Room Augmentation (Ambient Noise, p=0.4).
|
| 8 |
+
Supports three streaming data sources:
|
| 9 |
+
- Mode A: Hugging Face Streaming (Bingsu/zeroth-korean)
|
| 10 |
+
- Mode B: AI Hub ZIP In-Memory Reader (KsponSpeech / Lecture speech)
|
| 11 |
+
- Mode C: AI Hub Relay Reader (100GB split zip volumes TS.z01, TS.z02, ... or folders)
|
| 12 |
+
Slices 768d output to 256d (MRL) and caches bfloat16 .pt files in data/cache_features/.
|
| 13 |
+
|
| 14 |
+
Optimizations (100% Bit-Exact & Training-Compatible):
|
| 15 |
+
1. 3-Stage Asynchronous Pipeline:
|
| 16 |
+
- Stage 1 (CPU Producer Thread): Archive streaming + resampling + noise augmentation + Mel-spectrogram extraction & bucket padding + early CTC length validation (T >= L).
|
| 17 |
+
- Stage 2 (Main MPS GPU Thread): Dedicated 100% to GPU forward pass without CPU/disk stalls or per-sample empty_cache() thrashing.
|
| 18 |
+
- Stage 3 (Async Disk Writer Thread): Non-blocking atomic .pt.tmp -> .pt disk writes.
|
| 19 |
+
2. Bit-Exact Model & Kernel Acceleration:
|
| 20 |
+
- Bypasses redundant AutoTokenizer string construction/parsing per audio sample and extracts only valid Mel frames via zero-copy strided STFT.
|
| 21 |
+
- Caches 5D boolean Audio Attention Masks, 4D LM Full/Sliding Masks, RoPE (cos, sin), and prompt token embeddings per (n_audio, n_audio_pad) bucket.
|
| 22 |
+
- Fuses Q/K/V projections across all 12 Audio Attention layers and all 24 LM Attention layers into single contiguous GEMMs, and fuses Gate/Up projections across all 24 LM MLP layers.
|
| 23 |
+
- Replaces Tensor-bound clamps in Gemma4ClippableLinear with direct scalar-bound clamps + F.linear, and dispatches native F.scaled_dot_product_attention directly.
|
| 24 |
+
- Supports dynamic same-bucket GPU batching (--batch_size) to amortize 577M model weight reads across multiple samples sharing the same 64-token bucket.
|
| 25 |
+
- Precomputes frozen Gemma4AudioAttention relative key projections and softplus scales across all 12 audio layers.
|
| 26 |
+
- Pre-caches float32 RMSNorm weights with native torch.rsqrt across all 302 RMSNorm modules and eliminates redundant 1e10 activation clamps in eval mode.
|
| 27 |
+
- Pre-slices LM final embedding_projection weights to 256 dims (MRL) before D2H copy.
|
| 28 |
+
3. O(1) In-Memory Set Resume & Multi-Volume Split ZIP Scanner:
|
| 29 |
+
- Eliminates hundreds of thousands of disk stat() calls and SQLite lookups when resuming.
|
| 30 |
+
- Continuously scans across split ZIP boundaries (e.g. TS.z02) where leading bytes contain continuation data from the previous volume.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
import argparse
|
| 36 |
+
import datetime
|
| 37 |
+
import gc
|
| 38 |
+
import io
|
| 39 |
+
import json
|
| 40 |
+
import math
|
| 41 |
+
import os
|
| 42 |
+
import queue
|
| 43 |
+
import random
|
| 44 |
+
import struct
|
| 45 |
+
import sys
|
| 46 |
+
import threading
|
| 47 |
+
import time
|
| 48 |
+
import zipfile
|
| 49 |
+
import zlib
|
| 50 |
+
from collections import defaultdict
|
| 51 |
+
from pathlib import Path
|
| 52 |
+
from typing import Generator, Tuple, Dict, Any, Optional, Iterator, Set, List
|
| 53 |
+
|
| 54 |
+
# Auto-detect and switch to project virtualenv if running outside of it
|
| 55 |
+
_project_root = Path(__file__).resolve().parent.parent
|
| 56 |
+
_venv_python = _project_root / "venv" / "bin" / "python"
|
| 57 |
+
if _venv_python.exists():
|
| 58 |
+
try:
|
| 59 |
+
if Path(sys.executable).resolve() != _venv_python.resolve():
|
| 60 |
+
print(f"🔄 [Auto-Env] Switching Python interpreter:\n System: {sys.executable}\n Virtual: {_venv_python}\n", flush=True)
|
| 61 |
+
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
|
| 62 |
+
except Exception as _e:
|
| 63 |
+
print(f"⚠️ [Auto-Env] Warning: Could not auto-switch to venv: {_e}", flush=True)
|
| 64 |
+
|
| 65 |
+
_src_dir = str(Path(__file__).resolve().parent)
|
| 66 |
+
_proj_dir = str(Path(__file__).resolve().parent.parent)
|
| 67 |
+
if _src_dir not in sys.path:
|
| 68 |
+
sys.path.insert(0, _src_dir)
|
| 69 |
+
if _proj_dir not in sys.path:
|
| 70 |
+
sys.path.insert(0, _proj_dir)
|
| 71 |
+
|
| 72 |
+
# Strictly cap macOS MPS Unified Memory cache to prevent swap explosion
|
| 73 |
+
os.environ.setdefault("PYTORCH_MPS_HIGH_WATERMARK_RATIO", "0.7")
|
| 74 |
+
os.environ.setdefault("PYTORCH_MPS_LOW_WATERMARK_RATIO", "0.5")
|
| 75 |
+
|
| 76 |
+
import numpy as np
|
| 77 |
+
import soundfile as sf
|
| 78 |
+
import torch
|
| 79 |
+
import torch.nn.functional as F
|
| 80 |
+
import torchaudio
|
| 81 |
+
import torchaudio.functional as AF
|
| 82 |
+
import torchaudio.transforms as T
|
| 83 |
+
import yaml
|
| 84 |
+
from tqdm import tqdm
|
| 85 |
+
|
| 86 |
+
from dataset import normalize_text
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ==============================================================================
|
| 90 |
+
# 1. Lecture Room Augmenter (Reverb & Ambient Noise)
|
| 91 |
+
# ==============================================================================
|
| 92 |
+
|
| 93 |
+
class LectureRoomAugmenter:
|
| 94 |
+
"""
|
| 95 |
+
Simulates lecture hall ambient acoustics:
|
| 96 |
+
- Probabilistic ambient HVAC / audience background noise with controllable SNR
|
| 97 |
+
- (Reverb / RIR removed per configuration)
|
| 98 |
+
"""
|
| 99 |
+
def __init__(
|
| 100 |
+
self,
|
| 101 |
+
sample_rate: int = 16000,
|
| 102 |
+
rir_prob: float = 0.0,
|
| 103 |
+
noise_prob: float = 0.4,
|
| 104 |
+
snr_db_low: float = 15.0,
|
| 105 |
+
snr_db_high: float = 30.0
|
| 106 |
+
):
|
| 107 |
+
self.sr = sample_rate
|
| 108 |
+
self.rir_prob = 0.0
|
| 109 |
+
self.noise_prob = noise_prob
|
| 110 |
+
self.snr_db_low = snr_db_low
|
| 111 |
+
self.snr_db_high = snr_db_high
|
| 112 |
+
|
| 113 |
+
def add_background_noise(self, waveform: torch.Tensor, snr_db: float) -> torch.Tensor:
|
| 114 |
+
"""Adds ambient white/pink noise matching lecture hall background."""
|
| 115 |
+
signal_power = torch.mean(waveform ** 2) + 1e-12
|
| 116 |
+
snr_linear = 10.0 ** (snr_db / 10.0)
|
| 117 |
+
noise_power = signal_power / snr_linear
|
| 118 |
+
|
| 119 |
+
noise = torch.randn_like(waveform)
|
| 120 |
+
noise = 0.7 * noise + 0.3 * torch.roll(noise, 1, dims=-1)
|
| 121 |
+
current_noise_power = torch.mean(noise ** 2) + 1e-12
|
| 122 |
+
scaled_noise = noise * torch.sqrt(noise_power / current_noise_power)
|
| 123 |
+
return waveform + scaled_noise
|
| 124 |
+
|
| 125 |
+
def __call__(self, waveform: torch.Tensor) -> torch.Tensor:
|
| 126 |
+
if waveform.dim() == 1:
|
| 127 |
+
waveform = waveform.unsqueeze(0)
|
| 128 |
+
|
| 129 |
+
if random.random() < self.noise_prob:
|
| 130 |
+
snr = random.uniform(self.snr_db_low, self.snr_db_high)
|
| 131 |
+
waveform = self.add_background_noise(waveform, snr_db=snr)
|
| 132 |
+
|
| 133 |
+
max_val = torch.max(torch.abs(waveform))
|
| 134 |
+
if max_val > 0.95:
|
| 135 |
+
waveform = waveform / (max_val / 0.95)
|
| 136 |
+
|
| 137 |
+
return waveform
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# ==============================================================================
|
| 141 |
+
# 2. Mode A: Hugging Face Streaming Loader (Zeroth-Korean)
|
| 142 |
+
# ==============================================================================
|
| 143 |
+
|
| 144 |
+
def stream_zeroth_korean(
|
| 145 |
+
dataset_name: str = "Bingsu/zeroth-korean",
|
| 146 |
+
split: str = "train",
|
| 147 |
+
max_samples: Optional[int] = None
|
| 148 |
+
) -> Generator[Tuple[torch.Tensor, int, str], None, None]:
|
| 149 |
+
"""
|
| 150 |
+
Streams audio directly from Hugging Face Datasets without downloading whole dataset to disk.
|
| 151 |
+
Yields (waveform_tensor, sample_rate, transcription_text).
|
| 152 |
+
Uses Audio(decode=False) + soundfile to decode raw bytes cleanly without torchcodec dependency.
|
| 153 |
+
"""
|
| 154 |
+
print(f"[Step 2] Initializing Hugging Face Streaming on '{dataset_name}' (split='{split}')...")
|
| 155 |
+
from datasets import load_dataset, Audio
|
| 156 |
+
|
| 157 |
+
ds = load_dataset(dataset_name, split=split, streaming=True)
|
| 158 |
+
# Disable torchcodec auto-decoder by receiving raw audio bytes
|
| 159 |
+
ds = ds.cast_column("audio", Audio(decode=False))
|
| 160 |
+
count = 0
|
| 161 |
+
|
| 162 |
+
for sample in ds:
|
| 163 |
+
audio_info = sample.get("audio", {})
|
| 164 |
+
raw_bytes = audio_info.get("bytes")
|
| 165 |
+
text = sample.get("text", "").strip()
|
| 166 |
+
|
| 167 |
+
if not text:
|
| 168 |
+
continue
|
| 169 |
+
|
| 170 |
+
if raw_bytes is not None:
|
| 171 |
+
try:
|
| 172 |
+
data, sr = sf.read(io.BytesIO(raw_bytes), dtype="float32")
|
| 173 |
+
if data.ndim > 1:
|
| 174 |
+
data = data.mean(axis=1)
|
| 175 |
+
wav = torch.from_numpy(data).unsqueeze(0)
|
| 176 |
+
except Exception:
|
| 177 |
+
continue
|
| 178 |
+
elif "array" in audio_info:
|
| 179 |
+
wav = torch.from_numpy(np.array(audio_info["array"], dtype=np.float32)).unsqueeze(0)
|
| 180 |
+
sr = audio_info.get("sampling_rate", 16000)
|
| 181 |
+
else:
|
| 182 |
+
continue
|
| 183 |
+
|
| 184 |
+
yield wav, sr, text
|
| 185 |
+
|
| 186 |
+
count += 1
|
| 187 |
+
if max_samples is not None and count >= max_samples:
|
| 188 |
+
break
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
class PrefetchedStream:
|
| 192 |
+
"""
|
| 193 |
+
Asynchronous background prefetcher that pipelines I/O and CPU preprocessing
|
| 194 |
+
with MPS GPU inference, eliminating CPU and disk/network stalls.
|
| 195 |
+
"""
|
| 196 |
+
def __init__(self, generator: Iterator, max_queue_size: int = 16):
|
| 197 |
+
self.generator = generator
|
| 198 |
+
self.q: queue.Queue = queue.Queue(maxsize=max_queue_size)
|
| 199 |
+
self.stop_event = threading.Event()
|
| 200 |
+
self.worker = threading.Thread(target=self._produce, daemon=True)
|
| 201 |
+
self.worker.start()
|
| 202 |
+
|
| 203 |
+
def _produce(self):
|
| 204 |
+
try:
|
| 205 |
+
for item in self.generator:
|
| 206 |
+
if self.stop_event.is_set():
|
| 207 |
+
break
|
| 208 |
+
self.q.put(item)
|
| 209 |
+
except Exception as e:
|
| 210 |
+
print(f"[Step 2 Prefetch Warning] Streaming producer exception: {e}")
|
| 211 |
+
finally:
|
| 212 |
+
self.q.put(None)
|
| 213 |
+
|
| 214 |
+
def __iter__(self):
|
| 215 |
+
while True:
|
| 216 |
+
item = self.q.get()
|
| 217 |
+
if item is None:
|
| 218 |
+
break
|
| 219 |
+
yield item
|
| 220 |
+
|
| 221 |
+
def close(self):
|
| 222 |
+
self.stop_event.set()
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
class AsyncDiskSaver:
|
| 226 |
+
"""
|
| 227 |
+
Asynchronous background disk writer that performs atomic .pt.tmp -> .pt saves
|
| 228 |
+
and optional source file cleanup off the main MPS GPU thread.
|
| 229 |
+
"""
|
| 230 |
+
def __init__(self, max_queue_size: int = 32, delete_source_after_extract: bool = False):
|
| 231 |
+
self.q: queue.Queue = queue.Queue(maxsize=max_queue_size)
|
| 232 |
+
self.delete_source = delete_source_after_extract
|
| 233 |
+
self.error: Optional[Exception] = None
|
| 234 |
+
self.worker = threading.Thread(target=self._consume, daemon=True)
|
| 235 |
+
self.worker.start()
|
| 236 |
+
|
| 237 |
+
def _consume(self):
|
| 238 |
+
while True:
|
| 239 |
+
item = self.q.get()
|
| 240 |
+
if item is None:
|
| 241 |
+
self.q.task_done()
|
| 242 |
+
break
|
| 243 |
+
payload, cache_file, src_path = item
|
| 244 |
+
try:
|
| 245 |
+
tmp_file = cache_file.with_suffix(".pt.tmp")
|
| 246 |
+
torch.save(payload, tmp_file)
|
| 247 |
+
os.replace(tmp_file, cache_file)
|
| 248 |
+
if self.delete_source and src_path is not None:
|
| 249 |
+
try:
|
| 250 |
+
src_path.unlink()
|
| 251 |
+
except Exception as de:
|
| 252 |
+
print(f"[Step 2 Warning] Could not delete {src_path}: {de}")
|
| 253 |
+
except Exception as e:
|
| 254 |
+
self.error = e
|
| 255 |
+
print(f"[Step 2 Disk Write Error] Failed to save {cache_file}: {e}")
|
| 256 |
+
finally:
|
| 257 |
+
self.q.task_done()
|
| 258 |
+
|
| 259 |
+
def save(self, payload: Dict[str, Any], cache_file: Path, src_path: Optional[Path] = None):
|
| 260 |
+
if self.error is not None:
|
| 261 |
+
raise RuntimeError(f"AsyncDiskSaver encountered fatal write error: {self.error}")
|
| 262 |
+
self.q.put((payload, cache_file, src_path))
|
| 263 |
+
|
| 264 |
+
def close(self):
|
| 265 |
+
self.q.put(None)
|
| 266 |
+
self.q.join()
|
| 267 |
+
self.worker.join(timeout=30.0)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
# ==============================================================================
|
| 271 |
+
# 3. Mode B & C: AI Hub ZIP & Split-Volume Relay Readers
|
| 272 |
+
# ==============================================================================
|
| 273 |
+
|
| 274 |
+
class AIHubZipReader:
|
| 275 |
+
"""
|
| 276 |
+
In-memory reader for large AI Hub speech archives (KsponSpeech, Lecture speech).
|
| 277 |
+
Directly extracts and decodes .wav / .pcm audio and corresponding .txt transcripts
|
| 278 |
+
from memory buffer without extracting archives to disk.
|
| 279 |
+
"""
|
| 280 |
+
def __init__(self, zip_path: str):
|
| 281 |
+
self.zip_path = Path(zip_path)
|
| 282 |
+
if not self.zip_path.exists():
|
| 283 |
+
raise FileNotFoundError(f"AI Hub ZIP archive not found at {self.zip_path}")
|
| 284 |
+
|
| 285 |
+
print(f"[Step 2] Indexing AI Hub ZIP archive: {self.zip_path.name}...")
|
| 286 |
+
self.zf = zipfile.ZipFile(self.zip_path, "r")
|
| 287 |
+
self.file_list = self.zf.namelist()
|
| 288 |
+
|
| 289 |
+
self.pairs: Dict[str, Dict[str, str]] = {}
|
| 290 |
+
for fname in self.file_list:
|
| 291 |
+
if fname.endswith("/") or fname.startswith("__MACOSX"):
|
| 292 |
+
continue
|
| 293 |
+
base, ext = os.path.splitext(fname)
|
| 294 |
+
ext = ext.lower()
|
| 295 |
+
if ext in [".wav", ".pcm", ".flac"]:
|
| 296 |
+
self.pairs.setdefault(base, {})["audio"] = fname
|
| 297 |
+
elif ext in [".txt", ".trn"]:
|
| 298 |
+
self.pairs.setdefault(base, {})["text"] = fname
|
| 299 |
+
|
| 300 |
+
self.valid_keys = [k for k, v in self.pairs.items() if "audio" in v and "text" in v]
|
| 301 |
+
print(f"[Step 2] Discovered {len(self.valid_keys):,} valid audio-text pairs in ZIP.")
|
| 302 |
+
|
| 303 |
+
def __len__(self) -> int:
|
| 304 |
+
return len(self.valid_keys)
|
| 305 |
+
|
| 306 |
+
def stream_samples(self, max_samples: Optional[int] = None) -> Generator[Tuple[torch.Tensor, int, str], None, None]:
|
| 307 |
+
keys = self.valid_keys
|
| 308 |
+
if max_samples is not None:
|
| 309 |
+
keys = keys[:max_samples]
|
| 310 |
+
|
| 311 |
+
for k in keys:
|
| 312 |
+
audio_fname = self.pairs[k]["audio"]
|
| 313 |
+
text_fname = self.pairs[k]["text"]
|
| 314 |
+
|
| 315 |
+
try:
|
| 316 |
+
raw_txt = self.zf.read(text_fname)
|
| 317 |
+
try:
|
| 318 |
+
text = raw_txt.decode("utf-8").strip()
|
| 319 |
+
except UnicodeDecodeError:
|
| 320 |
+
text = raw_txt.decode("cp949", errors="ignore").strip()
|
| 321 |
+
except Exception:
|
| 322 |
+
continue
|
| 323 |
+
|
| 324 |
+
try:
|
| 325 |
+
raw_audio = self.zf.read(audio_fname)
|
| 326 |
+
if audio_fname.lower().endswith(".pcm"):
|
| 327 |
+
pcm_arr = np.frombuffer(raw_audio, dtype=np.int16).astype(np.float32) / 32768.0
|
| 328 |
+
wav = torch.from_numpy(pcm_arr).unsqueeze(0)
|
| 329 |
+
sr = 16000
|
| 330 |
+
else:
|
| 331 |
+
audio_buf = io.BytesIO(raw_audio)
|
| 332 |
+
data, sr = sf.read(audio_buf, dtype="float32")
|
| 333 |
+
if data.ndim > 1:
|
| 334 |
+
data = data.mean(axis=1)
|
| 335 |
+
wav = torch.from_numpy(data).unsqueeze(0)
|
| 336 |
+
except Exception:
|
| 337 |
+
continue
|
| 338 |
+
|
| 339 |
+
yield wav, sr, text
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
class AIHubLectureRelayReader:
|
| 343 |
+
"""
|
| 344 |
+
Reader for AI Hub Korean College Lecture dataset in relay extraction mode.
|
| 345 |
+
Matches audio files (.wav in folder or in split zip / zip) with labels from aihub_label_index.db / TL.zip.
|
| 346 |
+
- Uses O(1) in-memory set lookup (existing_files) to skip already cached features with zero disk I/O.
|
| 347 |
+
- Continuously scans across split ZIP volume boundaries (e.g. TS.z02) where leading bytes
|
| 348 |
+
contain compressed data from the previous volume.
|
| 349 |
+
- Strictly skips junk files (*.irx*, *.part, .DS_Store, __MACOSX).
|
| 350 |
+
- Yields (waveform, sr, text, short_key, optional_source_path).
|
| 351 |
+
"""
|
| 352 |
+
def __init__(
|
| 353 |
+
self,
|
| 354 |
+
source_dir: str,
|
| 355 |
+
labels_source: Optional[str] = None,
|
| 356 |
+
cache_dir: Optional[str] = None,
|
| 357 |
+
max_samples: Optional[int] = None,
|
| 358 |
+
existing_files: Optional[Set[str]] = None
|
| 359 |
+
):
|
| 360 |
+
self.source_dir = Path(source_dir)
|
| 361 |
+
self.max_samples = max_samples
|
| 362 |
+
self.cache_dir = Path(cache_dir) if cache_dir else None
|
| 363 |
+
self.existing_files = existing_files
|
| 364 |
+
|
| 365 |
+
# Auto-resolve parent Training folder (contains 01.원천데이터 and 02.라벨링데이터)
|
| 366 |
+
if self.source_dir.is_dir():
|
| 367 |
+
candidate_raw = self.source_dir / "01.원천데이터"
|
| 368 |
+
candidate_lbl = self.source_dir / "02.라벨링데이터" / "TL.zip"
|
| 369 |
+
if not labels_source and candidate_lbl.exists():
|
| 370 |
+
labels_source = str(candidate_lbl)
|
| 371 |
+
if candidate_raw.is_dir():
|
| 372 |
+
print(f"[Step 2] Auto-detected AI Hub raw audio folder: {candidate_raw}")
|
| 373 |
+
self.source_dir = candidate_raw
|
| 374 |
+
|
| 375 |
+
# Load label index
|
| 376 |
+
if not labels_source:
|
| 377 |
+
default_labels_zip = Path("/Users/yeongchan/Downloads/137.한국어 대학 강의 데이터/01-1.정식개방데이터/Training/02.라벨링데이터/TL.zip")
|
| 378 |
+
labels_source = str(default_labels_zip)
|
| 379 |
+
|
| 380 |
+
print(f"[Step 2] Loading AI Hub label index from {labels_source}...")
|
| 381 |
+
from dataset import load_or_build_aihub_label_index
|
| 382 |
+
self.labels_map = load_or_build_aihub_label_index(tl_zip_path=labels_source)
|
| 383 |
+
print(f"[Step 2] Label index ready with {len(self.labels_map):,} entries.")
|
| 384 |
+
|
| 385 |
+
@staticmethod
|
| 386 |
+
def _seek_next_local_header(f: io.BufferedReader, initial_bytes: bytes = b"") -> bool:
|
| 387 |
+
"""
|
| 388 |
+
Scans forward in 1MB chunks until a valid ZIP local file header (PK\\x03\\x04) is found,
|
| 389 |
+
positioned right at the start of the 30-byte header.
|
| 390 |
+
Returns True if found, False on EOF or Central Directory (PK\\x01\\x02).
|
| 391 |
+
"""
|
| 392 |
+
buf = initial_bytes
|
| 393 |
+
chunk_size = 1024 * 1024 # 1MB scan chunks
|
| 394 |
+
while True:
|
| 395 |
+
pos = 0
|
| 396 |
+
while True:
|
| 397 |
+
idx = buf.find(b"PK\x03\x04", pos)
|
| 398 |
+
if idx == -1:
|
| 399 |
+
break
|
| 400 |
+
# Ensure we have at least 30 bytes to validate header fields
|
| 401 |
+
if len(buf) - idx < 30:
|
| 402 |
+
extra = f.read(30)
|
| 403 |
+
buf += extra
|
| 404 |
+
if len(buf) - idx < 30:
|
| 405 |
+
return False
|
| 406 |
+
hdr_cand = buf[idx : idx + 30]
|
| 407 |
+
_, ver, flag, method, _, _, _, _, _, fname_len, extra_len = struct.unpack("<4sHHHHHIIIHH", hdr_cand)
|
| 408 |
+
if method in (0, 8) and 0 < fname_len <= 512 and extra_len <= 4096:
|
| 409 |
+
# Valid local file header found; seek exactly to its start
|
| 410 |
+
f.seek(f.tell() - len(buf) + idx)
|
| 411 |
+
return True
|
| 412 |
+
pos = idx + 4
|
| 413 |
+
|
| 414 |
+
# Check if we reached Central Directory at the end of the final volume
|
| 415 |
+
if b"PK\x01\x02" in buf:
|
| 416 |
+
cd_idx = buf.find(b"PK\x01\x02")
|
| 417 |
+
if cd_idx != -1:
|
| 418 |
+
hdr_check = buf[cd_idx : cd_idx + 4]
|
| 419 |
+
if hdr_check == b"PK\x01\x02":
|
| 420 |
+
return False
|
| 421 |
+
|
| 422 |
+
chunk = f.read(chunk_size)
|
| 423 |
+
if not chunk:
|
| 424 |
+
return False
|
| 425 |
+
buf = buf[-30:] + chunk if len(buf) >= 30 else buf + chunk
|
| 426 |
+
|
| 427 |
+
def stream_samples(self) -> Generator[Tuple[torch.Tensor, int, str, str, Optional[Path]], None, None]:
|
| 428 |
+
if not self.source_dir.exists():
|
| 429 |
+
print(f"[Step 2 Warning] Source dir {self.source_dir} does not exist.")
|
| 430 |
+
return
|
| 431 |
+
|
| 432 |
+
import re
|
| 433 |
+
def _is_zip_archive(name: str) -> bool:
|
| 434 |
+
nl = name.lower()
|
| 435 |
+
return nl.endswith(".zip") or bool(re.match(r"^.+\.z\d{2,3}$", nl))
|
| 436 |
+
|
| 437 |
+
# 1. Discover split zip volumes / zip files (*.z01, *.z02, *.zip, etc.), strictly skipping .irx, .part, .DS_Store
|
| 438 |
+
archive_candidates = []
|
| 439 |
+
if self.source_dir.is_file():
|
| 440 |
+
archive_candidates.append(self.source_dir)
|
| 441 |
+
elif self.source_dir.is_dir():
|
| 442 |
+
for f in sorted(self.source_dir.iterdir()):
|
| 443 |
+
fname = f.name
|
| 444 |
+
if fname.startswith(".") or ".irx" in fname or ".part" in fname:
|
| 445 |
+
continue
|
| 446 |
+
if _is_zip_archive(fname):
|
| 447 |
+
archive_candidates.append(f)
|
| 448 |
+
|
| 449 |
+
count = 0
|
| 450 |
+
|
| 451 |
+
# Stream from archives (split zip / zip) without disk extraction
|
| 452 |
+
for arch in archive_candidates:
|
| 453 |
+
print(f"[Step 2] Streaming audio from archive: {arch.name}...")
|
| 454 |
+
try:
|
| 455 |
+
with open(arch, "rb", buffering=4 * 1024 * 1024) as f:
|
| 456 |
+
sig = f.read(4)
|
| 457 |
+
if sig != b"PK\x07\x08":
|
| 458 |
+
f.seek(0)
|
| 459 |
+
|
| 460 |
+
while True:
|
| 461 |
+
hdr = f.read(30)
|
| 462 |
+
if len(hdr) < 30:
|
| 463 |
+
break
|
| 464 |
+
if not hdr.startswith(b"PK\x03\x04"):
|
| 465 |
+
if not self._seek_next_local_header(f, hdr):
|
| 466 |
+
break
|
| 467 |
+
continue
|
| 468 |
+
|
| 469 |
+
sig, ver, flag, method, t_val, d_val, crc, comp_size, uncomp_size, fname_len, extra_len = struct.unpack("<4sHHHHHIIIHH", hdr)
|
| 470 |
+
if method not in (0, 8) or fname_len == 0 or fname_len > 512 or extra_len > 4096:
|
| 471 |
+
if not self._seek_next_local_header(f, hdr[4:]):
|
| 472 |
+
break
|
| 473 |
+
continue
|
| 474 |
+
|
| 475 |
+
fname_bytes = f.read(fname_len)
|
| 476 |
+
if len(fname_bytes) < fname_len:
|
| 477 |
+
break
|
| 478 |
+
if extra_len > 0:
|
| 479 |
+
f.seek(extra_len, os.SEEK_CUR)
|
| 480 |
+
fname = fname_bytes.decode("utf-8", errors="replace")
|
| 481 |
+
|
| 482 |
+
if not fname.lower().endswith((".wav", ".pcm", ".flac")) or fname.startswith("__MACOSX"):
|
| 483 |
+
if comp_size > 0:
|
| 484 |
+
f.seek(comp_size, os.SEEK_CUR)
|
| 485 |
+
continue
|
| 486 |
+
|
| 487 |
+
# Extract relative key and short key
|
| 488 |
+
rel_key = fname.rsplit(".", 1)[0]
|
| 489 |
+
parts = rel_key.split("/")
|
| 490 |
+
short_key = f"{parts[-2]}_{parts[-1]}" if len(parts) >= 2 else parts[-1]
|
| 491 |
+
target_pt_name = f"feat_aihub_{short_key}.pt"
|
| 492 |
+
|
| 493 |
+
# Fast O(1) in-memory Resume check: skip reading audio & SQLite lookup if already cached!
|
| 494 |
+
if self.existing_files is not None:
|
| 495 |
+
if target_pt_name in self.existing_files:
|
| 496 |
+
f.seek(comp_size, os.SEEK_CUR)
|
| 497 |
+
continue
|
| 498 |
+
elif self.cache_dir and (self.cache_dir / target_pt_name).exists():
|
| 499 |
+
f.seek(comp_size, os.SEEK_CUR)
|
| 500 |
+
continue
|
| 501 |
+
|
| 502 |
+
# Silent skip if label not in index (short_key is already the canonical key)
|
| 503 |
+
text = self.labels_map.get(short_key)
|
| 504 |
+
if not text:
|
| 505 |
+
f.seek(comp_size, os.SEEK_CUR)
|
| 506 |
+
continue
|
| 507 |
+
|
| 508 |
+
try:
|
| 509 |
+
comp_data = f.read(comp_size)
|
| 510 |
+
if len(comp_data) < comp_size:
|
| 511 |
+
# Truncated entry at the end of a split volume
|
| 512 |
+
break
|
| 513 |
+
decomp = zlib.decompress(comp_data, -15) if method == 8 else comp_data
|
| 514 |
+
data, sr = sf.read(io.BytesIO(decomp), dtype="float32")
|
| 515 |
+
if data.ndim > 1:
|
| 516 |
+
data = data.mean(axis=1)
|
| 517 |
+
wav = torch.from_numpy(data).unsqueeze(0)
|
| 518 |
+
except Exception:
|
| 519 |
+
# Silent skip on decode errors (e.g., split boundary)
|
| 520 |
+
continue
|
| 521 |
+
|
| 522 |
+
yield wav, sr, text, short_key, None
|
| 523 |
+
count += 1
|
| 524 |
+
if self.max_samples and count >= self.max_samples:
|
| 525 |
+
return
|
| 526 |
+
except Exception as e:
|
| 527 |
+
print(f"[Step 2 Warning] Error reading archive {arch}: {e}. Proceeding...")
|
| 528 |
+
|
| 529 |
+
# 2. Discover uncompressed standalone .wav files in directory
|
| 530 |
+
if self.source_dir.is_dir():
|
| 531 |
+
import glob
|
| 532 |
+
wav_patterns = [str(self.source_dir / "**/*.wav"), str(self.source_dir / "**/*.pcm")]
|
| 533 |
+
wav_files = []
|
| 534 |
+
for pat in wav_patterns:
|
| 535 |
+
for fpath in glob.glob(pat, recursive=True):
|
| 536 |
+
if ".irx" not in fpath and ".part" not in fpath and ".DS_Store" not in fpath and "__MACOSX" not in fpath:
|
| 537 |
+
wav_files.append(Path(fpath))
|
| 538 |
+
|
| 539 |
+
wav_files.sort()
|
| 540 |
+
for wf in wav_files:
|
| 541 |
+
try:
|
| 542 |
+
rel_key = str(wf.relative_to(self.source_dir)).rsplit(".", 1)[0]
|
| 543 |
+
except Exception:
|
| 544 |
+
rel_key = wf.stem
|
| 545 |
+
parts = rel_key.split("/")
|
| 546 |
+
short_key = f"{parts[-2]}_{parts[-1]}" if len(parts) >= 2 else parts[-1]
|
| 547 |
+
target_pt_name = f"feat_aihub_{short_key}.pt"
|
| 548 |
+
|
| 549 |
+
# Fast O(1) Resume check
|
| 550 |
+
if self.existing_files is not None:
|
| 551 |
+
if target_pt_name in self.existing_files:
|
| 552 |
+
continue
|
| 553 |
+
elif self.cache_dir and (self.cache_dir / target_pt_name).exists():
|
| 554 |
+
continue
|
| 555 |
+
|
| 556 |
+
# Silent skip if label not found
|
| 557 |
+
text = self.labels_map.get(short_key)
|
| 558 |
+
if not text:
|
| 559 |
+
continue
|
| 560 |
+
|
| 561 |
+
try:
|
| 562 |
+
data, sr = sf.read(str(wf), dtype="float32")
|
| 563 |
+
if data.ndim > 1:
|
| 564 |
+
data = data.mean(axis=1)
|
| 565 |
+
wav = torch.from_numpy(data).unsqueeze(0)
|
| 566 |
+
except Exception:
|
| 567 |
+
continue
|
| 568 |
+
|
| 569 |
+
yield wav, sr, text, short_key, wf
|
| 570 |
+
count += 1
|
| 571 |
+
if self.max_samples and count >= self.max_samples:
|
| 572 |
+
return
|
| 573 |
+
|
| 574 |
+
|
| 575 |
+
# ==============================================================================
|
| 576 |
+
# 4. Genuine 300M Audio Encoder Feature Extractor (Bit-Exact Accelerated)
|
| 577 |
+
# ==============================================================================
|
| 578 |
+
|
| 579 |
+
class GenuineAudioFeatureExtractor:
|
| 580 |
+
"""
|
| 581 |
+
Extracts features using the official google/embeddinggemma-2 multimodal pipeline:
|
| 582 |
+
- Audio processed by audio_tower (304.8M) + embed_audio (0.8M)
|
| 583 |
+
- Passed through language_model (271.0M) into the shared 768-dim multimodal embedding space
|
| 584 |
+
- Audio token representations sliced to 256-dim (MRL) and L2 normalized
|
| 585 |
+
This guarantees audio and text representations share the exact same geometric vector space,
|
| 586 |
+
producing bit-exact identical outputs while accelerating execution via precomputation and caching.
|
| 587 |
+
"""
|
| 588 |
+
def __init__(self, device: torch.device, model_name: str = "google/embeddinggemma-2"):
|
| 589 |
+
self.device = device
|
| 590 |
+
self.target_sr = 16000
|
| 591 |
+
self.audio_bucket = 64
|
| 592 |
+
|
| 593 |
+
print(f"[Step 2] Loading genuine '{model_name}' Multimodal Model on {device} (bfloat16)...")
|
| 594 |
+
try:
|
| 595 |
+
from transformers import AutoModel, AutoProcessor
|
| 596 |
+
self.processor = AutoProcessor.from_pretrained(model_name)
|
| 597 |
+
self.feature_extractor = self.processor.feature_extractor
|
| 598 |
+
self.model = AutoModel.from_pretrained(model_name, dtype=torch.bfloat16)
|
| 599 |
+
|
| 600 |
+
if not hasattr(self.model, "audio_tower") or not hasattr(self.model, "embed_audio") or not hasattr(self.model, "language_model"):
|
| 601 |
+
raise AttributeError(f"Model missing required modules. Available: {[n for n, _ in self.model.named_children()]}")
|
| 602 |
+
|
| 603 |
+
# Delete vision_tower to save unified memory
|
| 604 |
+
if hasattr(self.model, "vision_tower"):
|
| 605 |
+
del self.model.vision_tower
|
| 606 |
+
gc.collect()
|
| 607 |
+
|
| 608 |
+
if hasattr(self.model, "config"):
|
| 609 |
+
self.model.config.use_cache = False
|
| 610 |
+
if hasattr(self.model, "language_model") and hasattr(self.model.language_model, "config"):
|
| 611 |
+
self.model.language_model.config.use_cache = False
|
| 612 |
+
|
| 613 |
+
self.model = self.model.to(device)
|
| 614 |
+
self.model.eval()
|
| 615 |
+
|
| 616 |
+
#Strictly freeze all parameters
|
| 617 |
+
for p in self.model.parameters():
|
| 618 |
+
p.requires_grad = False
|
| 619 |
+
|
| 620 |
+
total_p = sum(p.numel() for p in self.model.parameters())
|
| 621 |
+
self._apply_bit_exact_optimizations()
|
| 622 |
+
print(f"[Step 2] Successfully loaded, FROZE, and optimized genuine Multimodal Model ({total_p:,} params, {total_p/1e6:.1f}M)!")
|
| 623 |
+
|
| 624 |
+
if torch.backends.mps.is_available():
|
| 625 |
+
torch.mps.empty_cache()
|
| 626 |
+
|
| 627 |
+
except Exception as e:
|
| 628 |
+
raise RuntimeError(
|
| 629 |
+
f"\n[FATAL ERROR] Failed to load genuine '{model_name}' Multimodal Model: {e}\n"
|
| 630 |
+
f"Underlying error: {e}\n"
|
| 631 |
+
f"Ensure you are running with updated transformers: pip install git+https://github.com/huggingface/transformers.git"
|
| 632 |
+
)
|
| 633 |
+
|
| 634 |
+
def _apply_bit_exact_optimizations(self) -> None:
|
| 635 |
+
"""
|
| 636 |
+
Applies mathematically and numerically bit-exact optimizations to the frozen
|
| 637 |
+
Audio Tower and Language Model layers:
|
| 638 |
+
1. Pre-caches float32 RMSNorm weights and uses native torch.rsqrt across all 302 RMSNorm modules.
|
| 639 |
+
2. Optimizes Gemma4ClippableLinear with pre-extracted scalar bounds and direct F.linear.
|
| 640 |
+
3. Fuses Q/K/V weights and precomputes relative positional key projections + softplus scales across all 12 Audio Attention layers.
|
| 641 |
+
4. Removes redundant 1e10 activation clamps in eval mode (values are already bounded by ClippableLinear).
|
| 642 |
+
5. Fuses Q/K/V weights + direct SDPA dispatch in all 24 LM Attention layers, and fuses Gate/Up weights in all 24 LM MLP layers.
|
| 643 |
+
6. Precomputes prompt token embeddings ([BOS, BOA], [EOA, EOS], [PAD]) and MRL 256d projection slice.
|
| 644 |
+
"""
|
| 645 |
+
from transformers.models.gemma4.modeling_gemma4 import (
|
| 646 |
+
Gemma4RMSNorm,
|
| 647 |
+
Gemma4ClippableLinear,
|
| 648 |
+
Gemma4AudioAttention,
|
| 649 |
+
Gemma4AudioLayer,
|
| 650 |
+
Gemma4AudioFeedForward,
|
| 651 |
+
Gemma4AudioLightConv1d,
|
| 652 |
+
create_bidirectional_mask as g4_create_mask,
|
| 653 |
+
sliding_window_mask_function,
|
| 654 |
+
)
|
| 655 |
+
from transformers.models.embedding_gemma2.modeling_embedding_gemma2 import (
|
| 656 |
+
EmbeddingGemma2RMSNorm,
|
| 657 |
+
EmbeddingGemma2Attention,
|
| 658 |
+
EmbeddingGemma2MLP,
|
| 659 |
+
EmbeddingGemma2TextPLEBlock,
|
| 660 |
+
EmbeddingGemma2EncoderLayer,
|
| 661 |
+
apply_rotary_pos_emb,
|
| 662 |
+
create_bidirectional_mask as eg2_create_mask,
|
| 663 |
+
create_bidirectional_sliding_window_mask,
|
| 664 |
+
)
|
| 665 |
+
|
| 666 |
+
self._g4_create_mask = g4_create_mask
|
| 667 |
+
self._sliding_window_mask_fn = sliding_window_mask_function
|
| 668 |
+
self._eg2_create_mask = eg2_create_mask
|
| 669 |
+
self._eg2_create_sliding_mask = create_bidirectional_sliding_window_mask
|
| 670 |
+
|
| 671 |
+
# 1. Pre-cache float32 weights in all RMSNorm layers & replace torch.pow(-0.5) with torch.rsqrt
|
| 672 |
+
for m in self.model.modules():
|
| 673 |
+
if isinstance(m, (Gemma4RMSNorm, EmbeddingGemma2RMSNorm)):
|
| 674 |
+
if getattr(m, "with_scale", True) and hasattr(m, "weight"):
|
| 675 |
+
m.register_buffer("_weight_f32", m.weight.detach().float(), persistent=False)
|
| 676 |
+
|
| 677 |
+
def _exact_fast_rmsnorm(norm_self, hidden_states: torch.Tensor) -> torch.Tensor:
|
| 678 |
+
x = hidden_states.float()
|
| 679 |
+
normed = x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + norm_self.eps)
|
| 680 |
+
if norm_self.with_scale:
|
| 681 |
+
normed = normed * norm_self._weight_f32
|
| 682 |
+
return normed.type_as(hidden_states)
|
| 683 |
+
|
| 684 |
+
Gemma4RMSNorm.forward = _exact_fast_rmsnorm
|
| 685 |
+
EmbeddingGemma2RMSNorm.forward = _exact_fast_rmsnorm
|
| 686 |
+
|
| 687 |
+
# 2. Optimize Gemma4ClippableLinear with Python float scalar bounds & direct F.linear
|
| 688 |
+
at = self.model.audio_tower
|
| 689 |
+
lm = self.model.language_model
|
| 690 |
+
for m in at.modules():
|
| 691 |
+
if isinstance(m, Gemma4ClippableLinear):
|
| 692 |
+
m._w = m.linear.weight
|
| 693 |
+
if m.use_clipped_linears:
|
| 694 |
+
m._imin = float(m.input_min.item())
|
| 695 |
+
m._imax = float(m.input_max.item())
|
| 696 |
+
m._omin = float(m.output_min.item())
|
| 697 |
+
m._omax = float(m.output_max.item())
|
| 698 |
+
|
| 699 |
+
def _fast_clippable_linear(c_self, hidden_states: torch.Tensor) -> torch.Tensor:
|
| 700 |
+
if c_self.use_clipped_linears:
|
| 701 |
+
hidden_states = torch.clamp(hidden_states, c_self._imin, c_self._imax)
|
| 702 |
+
return torch.clamp(F.linear(hidden_states, c_self._w), c_self._omin, c_self._omax)
|
| 703 |
+
return F.linear(hidden_states, c_self._w)
|
| 704 |
+
|
| 705 |
+
Gemma4ClippableLinear.forward = _fast_clippable_linear
|
| 706 |
+
|
| 707 |
+
# 3. Precompute constant relative position embeddings & fuse Q/K/V projections in Audio Tower
|
| 708 |
+
with torch.no_grad():
|
| 709 |
+
self._pos_emb_const = at.rel_pos_enc(
|
| 710 |
+
torch.empty(1, 1, at.config.hidden_size, dtype=torch.bfloat16, device=self.device)
|
| 711 |
+
)
|
| 712 |
+
for layer in at.layers:
|
| 713 |
+
attn = layer.self_attn
|
| 714 |
+
attn.register_buffer("_softplus_scale", F.softplus(attn.per_dim_scale), persistent=False)
|
| 715 |
+
rel_k = (
|
| 716 |
+
attn.relative_k_proj(self._pos_emb_const)
|
| 717 |
+
.view(-1, attn.num_heads, attn.head_dim)
|
| 718 |
+
.float()
|
| 719 |
+
.permute(1, 2, 0)
|
| 720 |
+
)
|
| 721 |
+
attn.register_buffer("_rel_k_perm", rel_k, persistent=False)
|
| 722 |
+
|
| 723 |
+
q_w = attn.q_proj.linear.weight
|
| 724 |
+
k_w = attn.k_proj.linear.weight
|
| 725 |
+
v_w = attn.v_proj.linear.weight
|
| 726 |
+
attn.register_buffer("_qkv_w", torch.cat([q_w, k_w, v_w], dim=0).contiguous(), persistent=False)
|
| 727 |
+
attn._qkv_dim = q_w.shape[0]
|
| 728 |
+
attn._qkv_imin = float(attn.q_proj.input_min.item())
|
| 729 |
+
attn._qkv_imax = float(attn.q_proj.input_max.item())
|
| 730 |
+
attn._q_omin = float(attn.q_proj.output_min.item())
|
| 731 |
+
attn._q_omax = float(attn.q_proj.output_max.item())
|
| 732 |
+
attn._k_omin = float(attn.k_proj.output_min.item())
|
| 733 |
+
attn._k_omax = float(attn.k_proj.output_max.item())
|
| 734 |
+
attn._v_omin = float(attn.v_proj.output_min.item())
|
| 735 |
+
attn._v_omax = float(attn.v_proj.output_max.item())
|
| 736 |
+
attn._invalid_val = float(attn.config.attention_invalid_logits_value)
|
| 737 |
+
del attn.q_proj.linear.weight, attn.k_proj.linear.weight, attn.v_proj.linear.weight
|
| 738 |
+
|
| 739 |
+
def _fast_audio_attn_forward(attn_self, hidden_states: torch.Tensor, position_embeddings: torch.Tensor, attention_mask: Optional[torch.Tensor] = None):
|
| 740 |
+
batch_size, seq_length, _ = hidden_states.shape
|
| 741 |
+
hidden_shape = (batch_size, seq_length, attn_self.num_heads, attn_self.head_dim)
|
| 742 |
+
D = attn_self._qkv_dim
|
| 743 |
+
|
| 744 |
+
x_c = torch.clamp(hidden_states, attn_self._qkv_imin, attn_self._qkv_imax)
|
| 745 |
+
qkv = F.linear(x_c, attn_self._qkv_w)
|
| 746 |
+
query_states = torch.clamp(qkv[..., :D], attn_self._q_omin, attn_self._q_omax).float().view(hidden_shape)
|
| 747 |
+
key_states = torch.clamp(qkv[..., D:2*D], attn_self._k_omin, attn_self._k_omax).float().view(hidden_shape)
|
| 748 |
+
value_states = torch.clamp(qkv[..., 2*D:], attn_self._v_omin, attn_self._v_omax).float().view(hidden_shape)
|
| 749 |
+
|
| 750 |
+
query_states = query_states * attn_self.q_scale * attn_self._softplus_scale
|
| 751 |
+
key_states = key_states * attn_self.k_scale
|
| 752 |
+
|
| 753 |
+
query_states = attn_self._convert_to_block(query_states)
|
| 754 |
+
key_states = attn_self._extract_block_context(key_states)
|
| 755 |
+
value_states = attn_self._extract_block_context(value_states)
|
| 756 |
+
num_blocks = query_states.shape[1]
|
| 757 |
+
|
| 758 |
+
queries = query_states.permute(0, 3, 1, 2, 4)
|
| 759 |
+
matrix_ac = queries @ key_states.permute(0, 3, 1, 4, 2)
|
| 760 |
+
|
| 761 |
+
queries_flat = queries.reshape(batch_size, attn_self.num_heads, -1, attn_self.head_dim)
|
| 762 |
+
matrix_bd = (queries_flat @ attn_self._rel_k_perm).reshape(
|
| 763 |
+
batch_size, attn_self.num_heads, num_blocks, attn_self.chunk_size, -1
|
| 764 |
+
)
|
| 765 |
+
matrix_bd = attn_self._rel_shift(matrix_bd)
|
| 766 |
+
|
| 767 |
+
attn_weights = (matrix_ac + matrix_bd) / attn_self.softcap
|
| 768 |
+
attn_weights = torch.tanh(attn_weights) * attn_self.softcap
|
| 769 |
+
|
| 770 |
+
if attention_mask is not None:
|
| 771 |
+
attn_weights = attn_weights.masked_fill(attention_mask, attn_self._invalid_val)
|
| 772 |
+
|
| 773 |
+
attn_weights = F.softmax(attn_weights, dim=-1, dtype=torch.float32).to(value_states.dtype)
|
| 774 |
+
attn_output = attn_weights @ value_states.permute(0, 3, 1, 2, 4)
|
| 775 |
+
attn_output = attn_output.permute(0, 2, 3, 1, 4).reshape(batch_size, num_blocks * attn_self.chunk_size, -1)
|
| 776 |
+
attn_output = attn_output[:, :seq_length].contiguous()
|
| 777 |
+
return attn_self.post(attn_output.to(hidden_states.dtype)), None
|
| 778 |
+
|
| 779 |
+
Gemma4AudioAttention.forward = _fast_audio_attn_forward
|
| 780 |
+
|
| 781 |
+
# 4. Eliminate redundant 1e10 clamp kernels in Audio FFN, LightConv1d, and AudioLayer
|
| 782 |
+
def _fast_ffn_forward(ffn_self, hidden_states: torch.Tensor) -> torch.Tensor:
|
| 783 |
+
residual = hidden_states
|
| 784 |
+
hidden_states = ffn_self.pre_layer_norm(hidden_states)
|
| 785 |
+
hidden_states = ffn_self.ffw_layer_2(ffn_self.act_fn(ffn_self.ffw_layer_1(hidden_states)))
|
| 786 |
+
hidden_states = ffn_self.post_layer_norm(hidden_states) * ffn_self.post_layer_scale
|
| 787 |
+
return hidden_states + residual
|
| 788 |
+
|
| 789 |
+
def _fast_lconv_forward(conv_self, hidden_states: torch.Tensor) -> torch.Tensor:
|
| 790 |
+
residual = hidden_states
|
| 791 |
+
hidden_states = F.glu(conv_self.linear_start(conv_self.pre_layer_norm(hidden_states)), dim=-1)
|
| 792 |
+
hidden_states = conv_self.depthwise_conv1d(hidden_states.transpose(1, 2)).transpose(1, 2)
|
| 793 |
+
hidden_states = conv_self.linear_end(conv_self.act_fn(conv_self.conv_norm(hidden_states)))
|
| 794 |
+
return hidden_states + residual
|
| 795 |
+
|
| 796 |
+
def _fast_layer_forward(layer_self, hidden_states: torch.Tensor, attention_mask: Optional[torch.Tensor], position_embeddings: torch.Tensor, **kwargs) -> torch.Tensor:
|
| 797 |
+
hidden_states = layer_self.feed_forward1(hidden_states)
|
| 798 |
+
residual = hidden_states
|
| 799 |
+
h_attn, _ = layer_self.self_attn(
|
| 800 |
+
hidden_states=layer_self.norm_pre_attn(hidden_states),
|
| 801 |
+
position_embeddings=position_embeddings,
|
| 802 |
+
attention_mask=attention_mask,
|
| 803 |
+
)
|
| 804 |
+
hidden_states = layer_self.norm_post_attn(h_attn) + residual
|
| 805 |
+
hidden_states = layer_self.lconv1d(hidden_states)
|
| 806 |
+
hidden_states = layer_self.feed_forward2(hidden_states)
|
| 807 |
+
return layer_self.norm_out(hidden_states)
|
| 808 |
+
|
| 809 |
+
Gemma4AudioFeedForward.forward = _fast_ffn_forward
|
| 810 |
+
Gemma4AudioLightConv1d.forward = _fast_lconv_forward
|
| 811 |
+
Gemma4AudioLayer.forward = _fast_layer_forward
|
| 812 |
+
|
| 813 |
+
# 5. Fuse Q/K/V and Gate/Up projections across all 24 Language Model layers + direct SDPA
|
| 814 |
+
with torch.no_grad():
|
| 815 |
+
for layer in lm.layers:
|
| 816 |
+
attn = layer.self_attn
|
| 817 |
+
q_w = attn.q_proj.weight
|
| 818 |
+
k_w = attn.k_proj.weight
|
| 819 |
+
v_w = attn.v_proj.weight
|
| 820 |
+
attn.register_buffer("_qkv_w", torch.cat([q_w, k_w, v_w], dim=0).contiguous(), persistent=False)
|
| 821 |
+
attn._q_dim = q_w.shape[0]
|
| 822 |
+
attn._kv_dim = k_w.shape[0]
|
| 823 |
+
attn._o_w = attn.o_proj.weight
|
| 824 |
+
del attn.q_proj.weight, attn.k_proj.weight, attn.v_proj.weight
|
| 825 |
+
|
| 826 |
+
mlp = layer.mlp
|
| 827 |
+
g_w = mlp.gate_proj.weight
|
| 828 |
+
u_w = mlp.up_proj.weight
|
| 829 |
+
mlp.register_buffer("_gu_w", torch.cat([g_w, u_w], dim=0).contiguous(), persistent=False)
|
| 830 |
+
mlp._inter_dim = mlp.intermediate_size
|
| 831 |
+
mlp._down_w = mlp.down_proj.weight
|
| 832 |
+
del mlp.gate_proj.weight, mlp.up_proj.weight
|
| 833 |
+
|
| 834 |
+
ple_b = layer.ple_block
|
| 835 |
+
ple_b._gate_w = ple_b.per_layer_input_gate.weight
|
| 836 |
+
ple_b._proj_w = ple_b.per_layer_projection.weight
|
| 837 |
+
layer._scalar_val = float(layer.layer_scalar.item())
|
| 838 |
+
|
| 839 |
+
def _fused_lm_mlp_forward(mlp_self, x: torch.Tensor) -> torch.Tensor:
|
| 840 |
+
gu = F.linear(x, mlp_self._gu_w)
|
| 841 |
+
I = mlp_self._inter_dim
|
| 842 |
+
return F.linear(mlp_self.act_fn(gu[..., :I]) * gu[..., I:], mlp_self._down_w)
|
| 843 |
+
|
| 844 |
+
def _fused_lm_attn_forward(attn_self, hidden_states: torch.Tensor, attention_mask: Optional[torch.Tensor] = None, position_embeddings: Optional[Tuple[torch.Tensor, torch.Tensor]] = None, **kwargs):
|
| 845 |
+
input_shape = hidden_states.shape[:-1]
|
| 846 |
+
hidden_shape = (*input_shape, -1, attn_self.head_dim)
|
| 847 |
+
Dq = attn_self._q_dim
|
| 848 |
+
Dkv = attn_self._kv_dim
|
| 849 |
+
|
| 850 |
+
qkv = F.linear(hidden_states, attn_self._qkv_w)
|
| 851 |
+
query_states = attn_self.q_norm(qkv[..., :Dq].view(hidden_shape).transpose(1, 2))
|
| 852 |
+
key_states = attn_self.k_norm(qkv[..., Dq:Dq+Dkv].view(hidden_shape).transpose(1, 2))
|
| 853 |
+
value_states = attn_self.v_norm(qkv[..., Dq+Dkv:].view(hidden_shape).transpose(1, 2))
|
| 854 |
+
|
| 855 |
+
cos, sin = position_embeddings
|
| 856 |
+
query_states, key_states = apply_rotary_pos_emb(query_states, key_states, cos, sin)
|
| 857 |
+
|
| 858 |
+
attn_output = F.scaled_dot_product_attention(
|
| 859 |
+
query_states,
|
| 860 |
+
key_states,
|
| 861 |
+
value_states,
|
| 862 |
+
attn_mask=attention_mask,
|
| 863 |
+
dropout_p=0.0,
|
| 864 |
+
scale=attn_self.scaling,
|
| 865 |
+
is_causal=False,
|
| 866 |
+
enable_gqa=attn_self.num_key_value_groups > 1,
|
| 867 |
+
)
|
| 868 |
+
attn_output = attn_output.transpose(1, 2).reshape(*input_shape, -1).contiguous()
|
| 869 |
+
return F.linear(attn_output, attn_self._o_w), None
|
| 870 |
+
|
| 871 |
+
def _fused_ple_block_forward(ple_self, hidden_states: torch.Tensor, per_layer_input: torch.Tensor) -> torch.Tensor:
|
| 872 |
+
h = ple_self.act_fn(F.linear(hidden_states, ple_self._gate_w)) * per_layer_input
|
| 873 |
+
h = ple_self.post_per_layer_input_norm(F.linear(h, ple_self._proj_w))
|
| 874 |
+
return hidden_states + h
|
| 875 |
+
|
| 876 |
+
def _fused_lm_layer_forward(layer_self, hidden_states: torch.Tensor, per_layer_input: torch.Tensor, attention_mask: Optional[torch.Tensor] = None, position_embeddings: Optional[Tuple[torch.Tensor, torch.Tensor]] = None, **kwargs) -> torch.Tensor:
|
| 877 |
+
h_attn, _ = layer_self.self_attn(
|
| 878 |
+
hidden_states=layer_self.input_layernorm(hidden_states),
|
| 879 |
+
attention_mask=attention_mask,
|
| 880 |
+
position_embeddings=position_embeddings,
|
| 881 |
+
)
|
| 882 |
+
hidden_states = hidden_states + layer_self.post_attention_layernorm(h_attn)
|
| 883 |
+
h_mlp = layer_self.mlp(layer_self.pre_feedforward_layernorm(hidden_states))
|
| 884 |
+
hidden_states = hidden_states + layer_self.post_feedforward_layernorm(h_mlp)
|
| 885 |
+
hidden_states = layer_self.ple_block(hidden_states, per_layer_input)
|
| 886 |
+
if layer_self._scalar_val != 1.0:
|
| 887 |
+
hidden_states = hidden_states * layer_self.layer_scalar
|
| 888 |
+
return hidden_states
|
| 889 |
+
|
| 890 |
+
EmbeddingGemma2MLP.forward = _fused_lm_mlp_forward
|
| 891 |
+
EmbeddingGemma2Attention.forward = _fused_lm_attn_forward
|
| 892 |
+
EmbeddingGemma2TextPLEBlock.forward = _fused_ple_block_forward
|
| 893 |
+
EmbeddingGemma2EncoderLayer.forward = _fused_lm_layer_forward
|
| 894 |
+
|
| 895 |
+
# 6. Precompute prompt token embeddings & MRL 256d projection matrix slice
|
| 896 |
+
self.audio_token_id = self.model.config.audio_token_id
|
| 897 |
+
self.pad_id = getattr(self.model.config.text_config, "pad_token_id", 0) or 0
|
| 898 |
+
|
| 899 |
+
with torch.no_grad():
|
| 900 |
+
sample_prompt = self.processor(
|
| 901 |
+
text="<|audio|>",
|
| 902 |
+
audio=np.zeros(1600, dtype=np.float32),
|
| 903 |
+
sampling_rate=self.target_sr,
|
| 904 |
+
return_tensors="pt"
|
| 905 |
+
)["input_ids"][0]
|
| 906 |
+
prefix_ids = sample_prompt[:2].unsqueeze(0).to(self.device)
|
| 907 |
+
suffix_ids = sample_prompt[-2:].unsqueeze(0).to(self.device)
|
| 908 |
+
pad_ids = torch.tensor([[self.pad_id]], dtype=torch.long, device=self.device)
|
| 909 |
+
|
| 910 |
+
embed_fn = self.model.get_input_embeddings()
|
| 911 |
+
self._prefix_emb = embed_fn(prefix_ids).detach() # (1, 2, 512)
|
| 912 |
+
self._suffix_emb = embed_fn(suffix_ids).detach() # (1, 2, 512)
|
| 913 |
+
self._pad_emb = embed_fn(pad_ids).detach() # (1, 1, 512)
|
| 914 |
+
self._proj_w_256 = lm.embedding_projection.weight[:256, :].detach().contiguous()
|
| 915 |
+
|
| 916 |
+
# Pre-cache STFT / Mel parameters for zero-copy CPU Mel extraction
|
| 917 |
+
fe = self.feature_extractor
|
| 918 |
+
self._mel_pad_left = fe.frame_length // 2
|
| 919 |
+
self._mel_frame_length = fe.frame_length
|
| 920 |
+
self._mel_hop_length = fe.hop_length
|
| 921 |
+
self._mel_fft_length = fe.fft_length
|
| 922 |
+
self._mel_window = fe.window
|
| 923 |
+
self._mel_filters = fe.mel_filters
|
| 924 |
+
self._mel_floor = fe.mel_floor
|
| 925 |
+
|
| 926 |
+
# Caches for attention masks, base embeddings, and RoPE (cos, sin)
|
| 927 |
+
self._audio_mask_cache: Dict[Tuple[int, int], Optional[torch.Tensor]] = {}
|
| 928 |
+
self._batched_win_cache: Dict[int, Tuple[torch.Tensor, torch.Tensor]] = {}
|
| 929 |
+
self._lm_entry_cache: Dict[Tuple[int, int], Tuple[Dict[str, Optional[torch.Tensor]], torch.Tensor]] = {}
|
| 930 |
+
self._rope_cache: Dict[int, Dict[str, Tuple[torch.Tensor, torch.Tensor]]] = {}
|
| 931 |
+
gc.collect()
|
| 932 |
+
|
| 933 |
+
def resample_if_needed(self, wav: torch.Tensor, orig_sr: int) -> torch.Tensor:
|
| 934 |
+
if orig_sr != self.target_sr:
|
| 935 |
+
if not hasattr(self, "_resamplers"):
|
| 936 |
+
self._resamplers: Dict[int, T.Resample] = {}
|
| 937 |
+
resampler = self._resamplers.get(orig_sr)
|
| 938 |
+
if resampler is None:
|
| 939 |
+
resampler = T.Resample(orig_sr, self.target_sr)
|
| 940 |
+
self._resamplers[orig_sr] = resampler
|
| 941 |
+
wav = resampler(wav)
|
| 942 |
+
return wav
|
| 943 |
+
|
| 944 |
+
def preprocess_audio_cpu(self, wav: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor, int, int]:
|
| 945 |
+
"""
|
| 946 |
+
CPU-only preprocessing stage (safe to run in background producer thread):
|
| 947 |
+
Computes Mel-spectrogram features and masks matching Gemma4AudioFeatureExtractor bit-for-bit,
|
| 948 |
+
bypassing redundant string construction, tokenizer calls, and padded STFT frames,
|
| 949 |
+
and pads to 64-token buckets.
|
| 950 |
+
Returns (f_in_bf16, fm_in_bool, n_audio, n_audio_pad).
|
| 951 |
+
"""
|
| 952 |
+
if isinstance(wav, torch.Tensor):
|
| 953 |
+
wav_np = wav.detach().cpu().numpy().reshape(-1)
|
| 954 |
+
else:
|
| 955 |
+
wav_np = np.asarray(wav, dtype=np.float32).reshape(-1)
|
| 956 |
+
|
| 957 |
+
if wav_np.dtype != np.float32:
|
| 958 |
+
wav_np = wav_np.astype(np.float32)
|
| 959 |
+
|
| 960 |
+
raw_len = min(wav_np.shape[0], 480_000)
|
| 961 |
+
hop_length = self._mel_hop_length
|
| 962 |
+
pad_left = self._mel_pad_left
|
| 963 |
+
frame_length = self._mel_frame_length
|
| 964 |
+
|
| 965 |
+
valid_frames = max(0, (raw_len - 160 + hop_length - 1) // hop_length)
|
| 966 |
+
n_audio = (valid_frames + 3) // 4
|
| 967 |
+
n_audio_pad = ((n_audio + self.audio_bucket - 1) // self.audio_bucket) * self.audio_bucket
|
| 968 |
+
mel_target = n_audio_pad * 4
|
| 969 |
+
copy_len = min(valid_frames, mel_target)
|
| 970 |
+
|
| 971 |
+
f_in = torch.zeros((1, mel_target, 128), dtype=torch.bfloat16)
|
| 972 |
+
fm_in = torch.zeros((1, mel_target), dtype=torch.bool)
|
| 973 |
+
|
| 974 |
+
if copy_len > 0:
|
| 975 |
+
needed_len = pad_left + (copy_len - 1) * hop_length + frame_length
|
| 976 |
+
buf = np.zeros(needed_len, dtype=np.float32)
|
| 977 |
+
buf[pad_left : pad_left + raw_len] = wav_np[:raw_len]
|
| 978 |
+
frames = np.lib.stride_tricks.as_strided(
|
| 979 |
+
buf,
|
| 980 |
+
shape=(copy_len, frame_length),
|
| 981 |
+
strides=(buf.strides[0] * hop_length, buf.strides[0]),
|
| 982 |
+
)
|
| 983 |
+
stft = np.fft.rfft(frames * self._mel_window, n=self._mel_fft_length, axis=-1)
|
| 984 |
+
mel_spec = np.matmul(np.abs(stft), self._mel_filters)
|
| 985 |
+
log_mel = np.log(mel_spec + self._mel_floor).astype(np.float32)
|
| 986 |
+
f_in[0, :copy_len] = torch.from_numpy(log_mel).to(torch.bfloat16)
|
| 987 |
+
fm_in[0, :copy_len] = True
|
| 988 |
+
|
| 989 |
+
return f_in, fm_in, n_audio, n_audio_pad
|
| 990 |
+
|
| 991 |
+
def _get_batched_audio_mask(self, n_t: torch.Tensor, n_audio_pad: int) -> torch.Tensor:
|
| 992 |
+
"""
|
| 993 |
+
Builds the 5D boolean blocked local-attention mask (B, 1, num_blocks, chunk_size, context_size)
|
| 994 |
+
directly on device for a same-bucket batch.
|
| 995 |
+
"""
|
| 996 |
+
at = self.model.audio_tower
|
| 997 |
+
entry = self._batched_win_cache.get(n_audio_pad)
|
| 998 |
+
if entry is None:
|
| 999 |
+
C = at.config.attention_chunk_size
|
| 1000 |
+
L = at.config.attention_context_left - 1
|
| 1001 |
+
R = at.config.attention_context_right
|
| 1002 |
+
CTX = C + L + R
|
| 1003 |
+
NB = (n_audio_pad + C - 1) // C
|
| 1004 |
+
b = torch.arange(NB, device=self.device).view(NB, 1, 1)
|
| 1005 |
+
c = torch.arange(C, device=self.device).view(1, C, 1)
|
| 1006 |
+
j = torch.arange(CTX, device=self.device).view(1, 1, CTX)
|
| 1007 |
+
q = b * C + c
|
| 1008 |
+
k = b * C + j - L
|
| 1009 |
+
dist = q - k
|
| 1010 |
+
win = ((dist >= 0) & (dist < L)) | ((dist < 0) & (-dist < R))
|
| 1011 |
+
base_valid = win & (q < n_audio_pad) & (k >= 0) & (k < n_audio_pad)
|
| 1012 |
+
entry = (base_valid.unsqueeze(0), k.unsqueeze(0))
|
| 1013 |
+
self._batched_win_cache[n_audio_pad] = entry
|
| 1014 |
+
base_valid, k_idx = entry
|
| 1015 |
+
valid = base_valid & (k_idx < n_t.view(-1, 1, 1, 1))
|
| 1016 |
+
return (~valid).unsqueeze(1)
|
| 1017 |
+
|
| 1018 |
+
def extract_from_preprocessed(
|
| 1019 |
+
self,
|
| 1020 |
+
f_in: torch.Tensor,
|
| 1021 |
+
fm_in: torch.Tensor,
|
| 1022 |
+
n_audio: int,
|
| 1023 |
+
n_audio_pad: int
|
| 1024 |
+
) -> torch.Tensor:
|
| 1025 |
+
"""
|
| 1026 |
+
Executes MPS GPU forward pass on pre-padded Mel features using cached masks, RoPE,
|
| 1027 |
+
and prompt embeddings, returning the bit-exact (n_audio, 256) bfloat16 tensor on CPU.
|
| 1028 |
+
"""
|
| 1029 |
+
f_mps = f_in.to(self.device, dtype=torch.bfloat16)
|
| 1030 |
+
fm_mps = fm_in.to(self.device)
|
| 1031 |
+
tok_target = n_audio_pad + 4
|
| 1032 |
+
am_key = (n_audio, n_audio_pad)
|
| 1033 |
+
|
| 1034 |
+
at = self.model.audio_tower
|
| 1035 |
+
lm = self.model.language_model
|
| 1036 |
+
|
| 1037 |
+
with torch.no_grad():
|
| 1038 |
+
# 1. Audio Tower Forward
|
| 1039 |
+
hidden_states, output_mask = at.subsample_conv_projection(f_mps, fm_mps)
|
| 1040 |
+
if am_key in self._audio_mask_cache:
|
| 1041 |
+
audio_attn_mask = self._audio_mask_cache[am_key]
|
| 1042 |
+
else:
|
| 1043 |
+
m4d = self._g4_create_mask(
|
| 1044 |
+
config=at.config,
|
| 1045 |
+
inputs_embeds=hidden_states,
|
| 1046 |
+
attention_mask=output_mask,
|
| 1047 |
+
and_mask_function=self._sliding_window_mask_fn(
|
| 1048 |
+
(at.config.attention_context_left - 1, at.config.attention_context_right)
|
| 1049 |
+
),
|
| 1050 |
+
)
|
| 1051 |
+
audio_attn_mask = (at._convert_4d_mask_to_blocked_5d(m4d) != 0) if m4d is not None else None
|
| 1052 |
+
self._audio_mask_cache[am_key] = audio_attn_mask
|
| 1053 |
+
|
| 1054 |
+
for encoder_layer in at.layers:
|
| 1055 |
+
hidden_states = encoder_layer(
|
| 1056 |
+
hidden_states,
|
| 1057 |
+
attention_mask=audio_attn_mask,
|
| 1058 |
+
position_embeddings=self._pos_emb_const,
|
| 1059 |
+
)
|
| 1060 |
+
audio_proj = self.model.embed_audio(inputs_embeds=at.output_proj(hidden_states))
|
| 1061 |
+
|
| 1062 |
+
# 2. Language Model Forward with cached masks, base embeddings, and RoPE
|
| 1063 |
+
lm_entry = self._lm_entry_cache.get(am_key)
|
| 1064 |
+
if lm_entry is None:
|
| 1065 |
+
am_mps = torch.zeros((1, tok_target), dtype=torch.long, device=self.device)
|
| 1066 |
+
am_mps[:, : n_audio + 4] = 1
|
| 1067 |
+
dummy_emb = torch.empty((1, tok_target, lm.config.hidden_size), dtype=torch.bfloat16, device=self.device)
|
| 1068 |
+
mk = {"config": lm.config, "inputs_embeds": dummy_emb, "attention_mask": am_mps}
|
| 1069 |
+
am_map = {
|
| 1070 |
+
"full_attention": self._eg2_create_mask(**mk),
|
| 1071 |
+
"sliding_attention": self._eg2_create_sliding_mask(**mk),
|
| 1072 |
+
}
|
| 1073 |
+
base_embeds = self._pad_emb.expand(1, tok_target, -1).clone()
|
| 1074 |
+
base_embeds[:, :2, :] = self._prefix_emb
|
| 1075 |
+
base_embeds[:, 2 + n_audio : 4 + n_audio, :] = self._suffix_emb
|
| 1076 |
+
lm_entry = (am_map, base_embeds)
|
| 1077 |
+
self._lm_entry_cache[am_key] = lm_entry
|
| 1078 |
+
|
| 1079 |
+
am_map, base_embeds = lm_entry
|
| 1080 |
+
inputs_embeds = base_embeds.clone()
|
| 1081 |
+
inputs_embeds[:, 2 : 2 + n_audio, :] = audio_proj[:, :n_audio, :]
|
| 1082 |
+
|
| 1083 |
+
pos_emb = self._rope_cache.get(tok_target)
|
| 1084 |
+
if pos_emb is None:
|
| 1085 |
+
pos_ids = torch.arange(tok_target, device=self.device).unsqueeze(0)
|
| 1086 |
+
pos_emb = {lt: lm.rotary_emb(inputs_embeds, pos_ids, lt) for lt in lm.unique_layer_types}
|
| 1087 |
+
self._rope_cache[tok_target] = pos_emb
|
| 1088 |
+
|
| 1089 |
+
per_layer_inputs = lm.ple(inputs_embeds)
|
| 1090 |
+
h = inputs_embeds
|
| 1091 |
+
for i, encoder_layer in enumerate(lm.layers):
|
| 1092 |
+
lt = lm.config.layer_types[i]
|
| 1093 |
+
h = encoder_layer(
|
| 1094 |
+
h,
|
| 1095 |
+
per_layer_inputs[:, :, i, :],
|
| 1096 |
+
attention_mask=am_map[lt],
|
| 1097 |
+
position_embeddings=pos_emb[lt],
|
| 1098 |
+
)
|
| 1099 |
+
|
| 1100 |
+
h_normed = lm.norm(h)
|
| 1101 |
+
mrl_256 = F.linear(h_normed[:, 2 : 2 + n_audio, :], self._proj_w_256).cpu()[0]
|
| 1102 |
+
|
| 1103 |
+
norm_256 = F.normalize(mrl_256.float(), p=2, dim=-1).to(torch.bfloat16)
|
| 1104 |
+
return norm_256
|
| 1105 |
+
|
| 1106 |
+
def extract_batch_same_bucket(
|
| 1107 |
+
self,
|
| 1108 |
+
batch_items: List[Tuple[torch.Tensor, torch.Tensor, int, int]]
|
| 1109 |
+
) -> List[torch.Tensor]:
|
| 1110 |
+
"""
|
| 1111 |
+
Executes a batched MPS GPU forward pass for multiple preprocessed audio samples that share
|
| 1112 |
+
the exact same `n_audio_pad` bucket length (zero cross-bucket padding distortion).
|
| 1113 |
+
Falls back to the bit-exact single-sample path when len(batch_items) == 1.
|
| 1114 |
+
"""
|
| 1115 |
+
B = len(batch_items)
|
| 1116 |
+
if B == 1:
|
| 1117 |
+
return [self.extract_from_preprocessed(*batch_items[0])]
|
| 1118 |
+
|
| 1119 |
+
n_audio_pad = batch_items[0][3]
|
| 1120 |
+
n_list = [it[2] for it in batch_items]
|
| 1121 |
+
tok_target = n_audio_pad + 4
|
| 1122 |
+
|
| 1123 |
+
f_mps = torch.cat([it[0] for it in batch_items], dim=0).to(self.device, dtype=torch.bfloat16)
|
| 1124 |
+
fm_mps = torch.cat([it[1] for it in batch_items], dim=0).to(self.device)
|
| 1125 |
+
n_t = torch.tensor(n_list, device=self.device)
|
| 1126 |
+
|
| 1127 |
+
at = self.model.audio_tower
|
| 1128 |
+
lm = self.model.language_model
|
| 1129 |
+
|
| 1130 |
+
with torch.no_grad():
|
| 1131 |
+
hidden_states, _ = at.subsample_conv_projection(f_mps, fm_mps)
|
| 1132 |
+
audio_attn_mask = self._get_batched_audio_mask(n_t, n_audio_pad)
|
| 1133 |
+
|
| 1134 |
+
for encoder_layer in at.layers:
|
| 1135 |
+
hidden_states = encoder_layer(
|
| 1136 |
+
hidden_states,
|
| 1137 |
+
attention_mask=audio_attn_mask,
|
| 1138 |
+
position_embeddings=self._pos_emb_const,
|
| 1139 |
+
)
|
| 1140 |
+
audio_proj = self.model.embed_audio(inputs_embeds=at.output_proj(hidden_states))
|
| 1141 |
+
|
| 1142 |
+
t = torch.arange(tok_target, device=self.device).view(1, tok_target, 1)
|
| 1143 |
+
n3 = n_t.view(B, 1, 1)
|
| 1144 |
+
inputs_embeds = self._pad_emb.expand(B, tok_target, -1)
|
| 1145 |
+
shifted = F.pad(audio_proj, (0, 0, 2, 2))
|
| 1146 |
+
inputs_embeds = torch.where((t >= 2) & (t < 2 + n3), shifted, inputs_embeds)
|
| 1147 |
+
inputs_embeds = torch.where(t == 0, self._prefix_emb[:, 0:1], inputs_embeds)
|
| 1148 |
+
inputs_embeds = torch.where(t == 1, self._prefix_emb[:, 1:2], inputs_embeds)
|
| 1149 |
+
inputs_embeds = torch.where(t == 2 + n3, self._suffix_emb[:, 0:1], inputs_embeds)
|
| 1150 |
+
inputs_embeds = torch.where(t == 3 + n3, self._suffix_emb[:, 1:2], inputs_embeds)
|
| 1151 |
+
|
| 1152 |
+
am_mps = (torch.arange(tok_target, device=self.device).unsqueeze(0) < (n_t + 4).unsqueeze(1)).long()
|
| 1153 |
+
mk = {"config": lm.config, "inputs_embeds": inputs_embeds, "attention_mask": am_mps}
|
| 1154 |
+
am_map = {
|
| 1155 |
+
"full_attention": self._eg2_create_mask(**mk),
|
| 1156 |
+
"sliding_attention": self._eg2_create_sliding_mask(**mk),
|
| 1157 |
+
}
|
| 1158 |
+
|
| 1159 |
+
pos_emb = self._rope_cache.get(tok_target)
|
| 1160 |
+
if pos_emb is None:
|
| 1161 |
+
pos_ids = torch.arange(tok_target, device=self.device).unsqueeze(0)
|
| 1162 |
+
pos_emb = {lt: lm.rotary_emb(inputs_embeds[0:1], pos_ids, lt) for lt in lm.unique_layer_types}
|
| 1163 |
+
self._rope_cache[tok_target] = pos_emb
|
| 1164 |
+
|
| 1165 |
+
per_layer_inputs = lm.ple(inputs_embeds)
|
| 1166 |
+
h = inputs_embeds
|
| 1167 |
+
for i, encoder_layer in enumerate(lm.layers):
|
| 1168 |
+
lt = lm.config.layer_types[i]
|
| 1169 |
+
h = encoder_layer(
|
| 1170 |
+
h,
|
| 1171 |
+
per_layer_inputs[:, :, i, :],
|
| 1172 |
+
attention_mask=am_map[lt],
|
| 1173 |
+
position_embeddings=pos_emb[lt],
|
| 1174 |
+
)
|
| 1175 |
+
|
| 1176 |
+
mrl_256 = F.linear(lm.norm(h)[:, 2 : 2 + n_audio_pad, :], self._proj_w_256).cpu()
|
| 1177 |
+
|
| 1178 |
+
return [
|
| 1179 |
+
F.normalize(mrl_256[i, :n].float(), p=2, dim=-1).to(torch.bfloat16)
|
| 1180 |
+
for i, n in enumerate(n_list)
|
| 1181 |
+
]
|
| 1182 |
+
|
| 1183 |
+
def extract_features(self, wav: torch.Tensor) -> torch.Tensor:
|
| 1184 |
+
"""
|
| 1185 |
+
Extracts genuine (Time, 256) bfloat16 feature tensor from raw waveform in the shared multimodal space.
|
| 1186 |
+
Maintains full backward compatibility with standalone callers.
|
| 1187 |
+
"""
|
| 1188 |
+
f_in, fm_in, n_audio, n_audio_pad = self.preprocess_audio_cpu(wav)
|
| 1189 |
+
return self.extract_from_preprocessed(f_in, fm_in, n_audio, n_audio_pad)
|
| 1190 |
+
|
| 1191 |
+
|
| 1192 |
+
def format_time_hms(seconds: Optional[float]) -> str:
|
| 1193 |
+
"""초 단위를 'X시간 Y분 Z초', 'Y분 Z초', 'Z초' 형태로 사람이 읽기 쉽게 변환합니다."""
|
| 1194 |
+
if seconds is None or seconds < 0 or not math.isfinite(seconds):
|
| 1195 |
+
return "계산 중..."
|
| 1196 |
+
s = int(round(seconds))
|
| 1197 |
+
h = s // 3600
|
| 1198 |
+
m = (s % 3600) // 60
|
| 1199 |
+
sec = s % 60
|
| 1200 |
+
if h > 0:
|
| 1201 |
+
return f"{h}시간 {m:02d}분 {sec:02d}초"
|
| 1202 |
+
elif m > 0:
|
| 1203 |
+
return f"{m}분 {sec:02d}초"
|
| 1204 |
+
else:
|
| 1205 |
+
return f"{sec}초"
|
| 1206 |
+
|
| 1207 |
+
|
| 1208 |
+
# ==============================================================================
|
| 1209 |
+
# 5. Main Execution Entry
|
| 1210 |
+
# ==============================================================================
|
| 1211 |
+
|
| 1212 |
+
def main():
|
| 1213 |
+
parser = argparse.ArgumentParser(description="Step 2: Pre-extract and Cache Audio Features from Real 300M Audio Encoder")
|
| 1214 |
+
parser.add_argument("--config", type=str, default="configs/config.yaml", help="Path to config file")
|
| 1215 |
+
parser.add_argument("--mode", type=str, choices=["streaming", "zip", "aihub_relay"], default="streaming",
|
| 1216 |
+
help="Data source mode: 'streaming' (Zeroth-Korean), 'zip' (AI Hub single zip), or 'aihub_relay' (100GB batch relay)")
|
| 1217 |
+
parser.add_argument("--source_dir", type=str, default=None, help="Path to source directory or split zip volumes (e.g. TS.z01)")
|
| 1218 |
+
parser.add_argument("--zip_path", type=str, default=None, help="Path to AI Hub ZIP archive (required if mode=zip)")
|
| 1219 |
+
parser.add_argument("--audio_dir", type=str, default=None, help="Path to directory containing batch .wav audio files")
|
| 1220 |
+
parser.add_argument("--aihub_labels_zip", type=str, default=None, help="Path to AI Hub labeling TL.zip archive")
|
| 1221 |
+
parser.add_argument("--aihub_labels_dir", type=str, default=None, help="Path to AI Hub labeling directory containing json files")
|
| 1222 |
+
parser.add_argument("--delete_source_after_extract", action="store_true", help="Delete source .wav file after feature extraction to free disk space")
|
| 1223 |
+
parser.add_argument("--clean_cache", action="store_true", help="Clean existing cached features in data/cache_features before starting")
|
| 1224 |
+
parser.add_argument("--max_samples", type=int, default=None, help="Number of audio samples to extract and cache (default: all available in archive)")
|
| 1225 |
+
parser.add_argument("--split", type=str, default="train", help="Dataset split for streaming mode")
|
| 1226 |
+
parser.add_argument("--no_augment", action="store_true", help="Disable lecture room acoustic augmentation")
|
| 1227 |
+
parser.add_argument("--batch_size", type=int, default=8, help="Max same-bucket GPU batch size (default: 8; set 1 for strict single-sample mode)")
|
| 1228 |
+
args = parser.parse_args()
|
| 1229 |
+
|
| 1230 |
+
config_path = Path(args.config)
|
| 1231 |
+
if not config_path.is_absolute():
|
| 1232 |
+
config_path = Path(__file__).resolve().parent.parent / config_path
|
| 1233 |
+
|
| 1234 |
+
with open(config_path, "r", encoding="utf-8") as f:
|
| 1235 |
+
cfg = yaml.safe_load(f)
|
| 1236 |
+
|
| 1237 |
+
base_dir = Path(__file__).resolve().parent.parent
|
| 1238 |
+
cache_dir = base_dir / cfg["data"].get("cache_dir", "data/cache_features")
|
| 1239 |
+
|
| 1240 |
+
# Clean previous cached features only if explicitly requested
|
| 1241 |
+
if args.clean_cache and cache_dir.exists():
|
| 1242 |
+
print(f"[Step 2] Cleaning previous cached features in {cache_dir}...")
|
| 1243 |
+
for old_f in cache_dir.glob("*.pt"):
|
| 1244 |
+
try:
|
| 1245 |
+
old_f.unlink()
|
| 1246 |
+
except Exception:
|
| 1247 |
+
pass
|
| 1248 |
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
| 1249 |
+
|
| 1250 |
+
# Build O(1) in-memory set of existing .pt filenames in a single scandir pass (no extra stat() calls)
|
| 1251 |
+
with os.scandir(cache_dir) as it:
|
| 1252 |
+
existing_pt_files: Set[str] = {entry.name for entry in it if entry.name.endswith(".pt")}
|
| 1253 |
+
start_index = len(existing_pt_files)
|
| 1254 |
+
if start_index > 0:
|
| 1255 |
+
print(f"[Step 2] Incremental Relay Mode: {start_index:,} existing features indexed in RAM (O(1) resume lookup).")
|
| 1256 |
+
|
| 1257 |
+
# Initialize Augmenter (Reverb disabled; ambient noise only)
|
| 1258 |
+
aug_cfg = cfg["audio"].get("augmentation", {})
|
| 1259 |
+
do_augment = aug_cfg.get("enabled", True) and not args.no_augment
|
| 1260 |
+
augmenter = LectureRoomAugmenter(
|
| 1261 |
+
sample_rate=16000,
|
| 1262 |
+
rir_prob=0.0,
|
| 1263 |
+
noise_prob=aug_cfg.get("noise_prob", 0.4) if do_augment else 0.0,
|
| 1264 |
+
snr_db_low=aug_cfg.get("noise_snr_db_low", 15.0),
|
| 1265 |
+
snr_db_high=aug_cfg.get("noise_snr_db_high", 30.0)
|
| 1266 |
+
)
|
| 1267 |
+
|
| 1268 |
+
# Initialize Genuine Feature Extractor (300M Audio Encoder + 271M LM)
|
| 1269 |
+
device_name = cfg["system"].get("device", "mps")
|
| 1270 |
+
device = torch.device(device_name if (device_name == "mps" and torch.backends.mps.is_available()) else "cpu")
|
| 1271 |
+
extractor = GenuineAudioFeatureExtractor(device=device, model_name=cfg["model"].get("name", "google/embeddinggemma-2"))
|
| 1272 |
+
|
| 1273 |
+
# Determine Data Stream
|
| 1274 |
+
if args.mode == "aihub_relay":
|
| 1275 |
+
audio_src = args.source_dir or args.audio_dir or args.zip_path
|
| 1276 |
+
if not audio_src:
|
| 1277 |
+
default_audio_src = Path("/Users/yeongchan/Downloads/137.한국어 대학 강의 데이터/01-1.정식개방데이터/Training/01.원천데이터")
|
| 1278 |
+
if default_audio_src.exists():
|
| 1279 |
+
audio_src = str(default_audio_src)
|
| 1280 |
+
else:
|
| 1281 |
+
raise ValueError("--source_dir or --audio_dir required when --mode aihub_relay is specified.")
|
| 1282 |
+
labels_src = args.aihub_labels_zip or args.aihub_labels_dir
|
| 1283 |
+
if not labels_src:
|
| 1284 |
+
default_labels_zip = Path("/Users/yeongchan/Downloads/137.한국어 대학 강의 데이터/01-1.정식개방데이터/Training/02.라벨링데이터/TL.zip")
|
| 1285 |
+
if default_labels_zip.exists():
|
| 1286 |
+
labels_src = str(default_labels_zip)
|
| 1287 |
+
else:
|
| 1288 |
+
raise ValueError("AI Hub labels source (TL.zip or folder) must be provided via --aihub_labels_zip or --aihub_labels_dir.")
|
| 1289 |
+
relay_reader = AIHubLectureRelayReader(
|
| 1290 |
+
source_dir=audio_src,
|
| 1291 |
+
labels_source=labels_src,
|
| 1292 |
+
cache_dir=str(cache_dir),
|
| 1293 |
+
max_samples=args.max_samples,
|
| 1294 |
+
existing_files=existing_pt_files
|
| 1295 |
+
)
|
| 1296 |
+
raw_stream = relay_reader.stream_samples()
|
| 1297 |
+
elif args.mode == "zip":
|
| 1298 |
+
if not args.zip_path:
|
| 1299 |
+
raise ValueError("--zip_path is required when --mode zip is specified.")
|
| 1300 |
+
zip_reader = AIHubZipReader(args.zip_path)
|
| 1301 |
+
raw_stream = ((w, s, t, None, None) for w, s, t in zip_reader.stream_samples(max_samples=args.max_samples))
|
| 1302 |
+
else:
|
| 1303 |
+
dataset_name = cfg["data"].get("hf_dataset_name", "Bingsu/zeroth-korean")
|
| 1304 |
+
raw_stream = ((w, s, t, None, None) for w, s, t in stream_zeroth_korean(dataset_name=dataset_name, split=args.split, max_samples=args.max_samples))
|
| 1305 |
+
|
| 1306 |
+
# Stage 1 Generator: Perform all CPU-bound work (resampling, noise augmentation,
|
| 1307 |
+
# Mel-spectrogram extraction, bucket padding, and early CTC length validation) in the background thread!
|
| 1308 |
+
def _cpu_preprocess_generator():
|
| 1309 |
+
seq_counter = start_index
|
| 1310 |
+
for wav, sr, text, short_key, src_path in raw_stream:
|
| 1311 |
+
clean_text = normalize_text(text)
|
| 1312 |
+
if not clean_text:
|
| 1313 |
+
continue
|
| 1314 |
+
|
| 1315 |
+
num_syllables = len(clean_text.replace(" ", ""))
|
| 1316 |
+
if num_syllables == 0:
|
| 1317 |
+
continue
|
| 1318 |
+
|
| 1319 |
+
if short_key:
|
| 1320 |
+
target_fname = f"feat_aihub_{short_key}.pt"
|
| 1321 |
+
else:
|
| 1322 |
+
while f"feat_{seq_counter:06d}.pt" in existing_pt_files:
|
| 1323 |
+
seq_counter += 1
|
| 1324 |
+
target_fname = f"feat_{seq_counter:06d}.pt"
|
| 1325 |
+
|
| 1326 |
+
if target_fname in existing_pt_files:
|
| 1327 |
+
continue
|
| 1328 |
+
|
| 1329 |
+
# 1. Resample to 16,000Hz mono
|
| 1330 |
+
wav = extractor.resample_if_needed(wav, orig_sr=sr)
|
| 1331 |
+
if wav.dim() > 1 and wav.size(0) > 1:
|
| 1332 |
+
wav = wav.mean(dim=0, keepdim=True)
|
| 1333 |
+
|
| 1334 |
+
# 2. Apply Lecture Room Augmentation (Ambient Noise only)
|
| 1335 |
+
if do_augment:
|
| 1336 |
+
wav = augmenter(wav)
|
| 1337 |
+
|
| 1338 |
+
# 3. Extract Mel-spectrogram & bucket padding on CPU
|
| 1339 |
+
f_in, fm_in, n_audio, n_audio_pad = extractor.preprocess_audio_cpu(wav)
|
| 1340 |
+
|
| 1341 |
+
# 4. Early CTC validity check BEFORE running expensive GPU inference (T >= L)
|
| 1342 |
+
if n_audio < num_syllables:
|
| 1343 |
+
continue
|
| 1344 |
+
|
| 1345 |
+
cache_file = cache_dir / target_fname
|
| 1346 |
+
if not short_key:
|
| 1347 |
+
seq_counter += 1
|
| 1348 |
+
|
| 1349 |
+
yield f_in, fm_in, n_audio, n_audio_pad, num_syllables, clean_text, cache_file, target_fname, src_path
|
| 1350 |
+
|
| 1351 |
+
# Wrap CPU preprocessing generator in asynchronous prefetcher (Stage 1) and disk saver (Stage 3)
|
| 1352 |
+
preprocessed_stream = PrefetchedStream(_cpu_preprocess_generator(), max_queue_size=32)
|
| 1353 |
+
disk_saver = AsyncDiskSaver(max_queue_size=64, delete_source_after_extract=args.delete_source_after_extract)
|
| 1354 |
+
|
| 1355 |
+
saved_count = start_index
|
| 1356 |
+
new_saved = 0
|
| 1357 |
+
max_bs = max(1, int(args.batch_size))
|
| 1358 |
+
token_budget = 1536 # Memory-safe token budget for 16GB Unified Memory
|
| 1359 |
+
bucket_queues: Dict[int, List[Tuple[Any, ...]]] = defaultdict(list)
|
| 1360 |
+
buffered_count = 0
|
| 1361 |
+
max_buffered = max_bs * 6
|
| 1362 |
+
|
| 1363 |
+
# Determine total expected extraction count for progress bar & remaining time calculation
|
| 1364 |
+
target_new: Optional[int] = None
|
| 1365 |
+
if args.max_samples and args.max_samples > 0:
|
| 1366 |
+
target_new = args.max_samples
|
| 1367 |
+
elif args.mode == "aihub_relay" and "relay_reader" in locals() and hasattr(relay_reader, "labels_map") and relay_reader.labels_map:
|
| 1368 |
+
target_new = max(0, len(relay_reader.labels_map) - start_index)
|
| 1369 |
+
elif args.mode == "zip" and "zip_reader" in locals() and hasattr(zip_reader, "valid_keys") and zip_reader.valid_keys:
|
| 1370 |
+
target_new = max(0, len(zip_reader.valid_keys) - start_index)
|
| 1371 |
+
|
| 1372 |
+
pbar = tqdm(
|
| 1373 |
+
total=target_new,
|
| 1374 |
+
desc="Extracting Genuine Features",
|
| 1375 |
+
dynamic_ncols=True
|
| 1376 |
+
)
|
| 1377 |
+
|
| 1378 |
+
start_time = time.time()
|
| 1379 |
+
|
| 1380 |
+
def _flush_bucket(n_audio_pad_key: int) -> bool:
|
| 1381 |
+
nonlocal saved_count, new_saved, buffered_count
|
| 1382 |
+
items = bucket_queues.get(n_audio_pad_key)
|
| 1383 |
+
if not items:
|
| 1384 |
+
return False
|
| 1385 |
+
|
| 1386 |
+
bs_cap = max(1, min(max_bs, token_budget // max(n_audio_pad_key, 64)))
|
| 1387 |
+
batch = items[:bs_cap]
|
| 1388 |
+
del items[:bs_cap]
|
| 1389 |
+
buffered_count -= len(batch)
|
| 1390 |
+
|
| 1391 |
+
# Stage 2: Dedicated MPS GPU Forward Pass (Same-Bucket Batched or Bit-Exact Single)
|
| 1392 |
+
pre_inputs = [(it[0], it[1], it[2], it[3]) for it in batch]
|
| 1393 |
+
feats_list = extractor.extract_batch_same_bucket(pre_inputs)
|
| 1394 |
+
|
| 1395 |
+
for it, features_256 in zip(batch, feats_list):
|
| 1396 |
+
_, _, _, _, num_syllables, clean_text, cache_file, target_fname, src_path = it
|
| 1397 |
+
if features_256.size(0) < num_syllables:
|
| 1398 |
+
continue
|
| 1399 |
+
|
| 1400 |
+
# Stage 3: Enqueue non-blocking atomic disk write
|
| 1401 |
+
existing_pt_files.add(target_fname)
|
| 1402 |
+
disk_saver.save(
|
| 1403 |
+
{
|
| 1404 |
+
"feature": features_256,
|
| 1405 |
+
"raw_text": clean_text
|
| 1406 |
+
},
|
| 1407 |
+
cache_file,
|
| 1408 |
+
src_path
|
| 1409 |
+
)
|
| 1410 |
+
|
| 1411 |
+
saved_count += 1
|
| 1412 |
+
new_saved += 1
|
| 1413 |
+
pbar.update(1)
|
| 1414 |
+
|
| 1415 |
+
if new_saved % 250 == 0:
|
| 1416 |
+
gc.collect()
|
| 1417 |
+
if torch.backends.mps.is_available():
|
| 1418 |
+
torch.mps.empty_cache()
|
| 1419 |
+
|
| 1420 |
+
if new_saved % 500 == 0:
|
| 1421 |
+
import shutil
|
| 1422 |
+
free_gb = shutil.disk_usage(cache_dir).free / (1024 ** 3)
|
| 1423 |
+
if free_gb < 5.0:
|
| 1424 |
+
print(f"\n⚠️ [Step 2 Disk Safety Alert] Remaining disk space is {free_gb:.2f} GB (< 5GB safety threshold). Gracefully concluding extraction.")
|
| 1425 |
+
return True
|
| 1426 |
+
|
| 1427 |
+
if args.max_samples and args.max_samples > 0 and new_saved >= args.max_samples:
|
| 1428 |
+
return True
|
| 1429 |
+
|
| 1430 |
+
return False
|
| 1431 |
+
|
| 1432 |
+
try:
|
| 1433 |
+
stop_early = False
|
| 1434 |
+
for item in preprocessed_stream:
|
| 1435 |
+
n_audio_pad = item[3]
|
| 1436 |
+
if max_bs <= 1:
|
| 1437 |
+
bucket_queues[n_audio_pad].append(item)
|
| 1438 |
+
buffered_count += 1
|
| 1439 |
+
if _flush_bucket(n_audio_pad):
|
| 1440 |
+
stop_early = True
|
| 1441 |
+
break
|
| 1442 |
+
else:
|
| 1443 |
+
bucket_queues[n_audio_pad].append(item)
|
| 1444 |
+
buffered_count += 1
|
| 1445 |
+
bs_cap = max(1, min(max_bs, token_budget // max(n_audio_pad, 64)))
|
| 1446 |
+
if len(bucket_queues[n_audio_pad]) >= bs_cap:
|
| 1447 |
+
if _flush_bucket(n_audio_pad):
|
| 1448 |
+
stop_early = True
|
| 1449 |
+
break
|
| 1450 |
+
elif buffered_count >= max_buffered:
|
| 1451 |
+
# Flush the fullest bucket to bound latency and RAM
|
| 1452 |
+
oldest_bucket = max(bucket_queues.keys(), key=lambda k: len(bucket_queues[k]))
|
| 1453 |
+
if _flush_bucket(oldest_bucket):
|
| 1454 |
+
stop_early = True
|
| 1455 |
+
break
|
| 1456 |
+
|
| 1457 |
+
if not stop_early:
|
| 1458 |
+
for k in sorted(list(bucket_queues.keys())):
|
| 1459 |
+
while bucket_queues[k]:
|
| 1460 |
+
if _flush_bucket(k):
|
| 1461 |
+
break
|
| 1462 |
+
finally:
|
| 1463 |
+
preprocessed_stream.close()
|
| 1464 |
+
disk_saver.close()
|
| 1465 |
+
pbar.close()
|
| 1466 |
+
|
| 1467 |
+
total_elapsed = time.time() - start_time
|
| 1468 |
+
avg_speed = new_saved / max(total_elapsed, 1e-5)
|
| 1469 |
+
print(f"\n[Step 2 Completed Successfully!]")
|
| 1470 |
+
print(f" Newly extracted features: {new_saved:,} (Total in cache: {saved_count:,})")
|
| 1471 |
+
print(f" Total time elapsed: {format_time_hms(total_elapsed)} (Average speed: {avg_speed:.2f} samples/s)")
|
| 1472 |
+
print(f" Target dimension: 256d (bfloat16), Sample rate: 16kHz")
|
| 1473 |
+
|
| 1474 |
+
|
| 1475 |
+
if __name__ == "__main__":
|
| 1476 |
+
main()
|
transcribe.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Notesoop-STT-Ko-v1: Standalone CLI Inference Script
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
python transcribe.py audio.mp3
|
| 7 |
+
python transcribe.py audio.wav --batch-size 4 --chunk-sec 16.0
|
| 8 |
+
python transcribe.py audio.m4a --output result.txt
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
import sys
|
| 15 |
+
import time
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
# Add current directory to path
|
| 19 |
+
_ROOT = Path(__file__).resolve().parent
|
| 20 |
+
if str(_ROOT) not in sys.path:
|
| 21 |
+
sys.path.insert(0, str(_ROOT))
|
| 22 |
+
|
| 23 |
+
from run_inference import EmbeddingGemmaSTT
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def main():
|
| 27 |
+
parser = argparse.ArgumentParser(
|
| 28 |
+
description="Notesoop-STT-Ko-v1: Ultra-Fast Korean On-Device Speech Recognition"
|
| 29 |
+
)
|
| 30 |
+
parser.add_argument("audio", type=str, help="Path to audio file (.wav, .m4a, .mp3, .flac, etc.)")
|
| 31 |
+
parser.add_argument("--batch-size", type=int, default=4, help="Batch size for parallel chunk processing (default: 4)")
|
| 32 |
+
parser.add_argument("--chunk-sec", type=float, default=16.0, help="Chunk length in seconds (default: 16.0s)")
|
| 33 |
+
parser.add_argument("--overlap-sec", type=float, default=1.0, help="Overlap length in seconds (default: 1.0s)")
|
| 34 |
+
parser.add_argument("--output", type=str, default=None, help="Optional text file path to save transcription")
|
| 35 |
+
parser.add_argument("--no-kenlm", action="store_true", help="Disable KenLM 3-Gram beam search and use Greedy CTC")
|
| 36 |
+
args = parser.parse_args()
|
| 37 |
+
|
| 38 |
+
audio_path = Path(args.audio)
|
| 39 |
+
if not audio_path.exists():
|
| 40 |
+
print(f"❌ Error: Audio file not found at '{audio_path}'", file=sys.stderr)
|
| 41 |
+
sys.exit(1)
|
| 42 |
+
|
| 43 |
+
print("=" * 70)
|
| 44 |
+
print("🌲 Notesoop-STT-Ko-v1: Korean On-Device Speech Recognition")
|
| 45 |
+
print(f" • Audio File : {audio_path.name}")
|
| 46 |
+
print(f" • Batch Size : {args.batch_size}")
|
| 47 |
+
print(f" • Chunking : {args.chunk_sec}s chunks (overlap {args.overlap_sec}s)")
|
| 48 |
+
print(f" • KenLM : {'Enabled (alpha=0.12, beta=0.40)' if not args.no_kenlm else 'Disabled (Greedy)'}")
|
| 49 |
+
print("=" * 70)
|
| 50 |
+
|
| 51 |
+
# Initialize Notesoop STT Engine
|
| 52 |
+
stt = EmbeddingGemmaSTT(
|
| 53 |
+
device="auto",
|
| 54 |
+
use_kenlm=(not args.no_kenlm),
|
| 55 |
+
lm_alpha=0.12,
|
| 56 |
+
lm_beta=0.40
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
print("\n🎙️ Transcribing audio...")
|
| 60 |
+
t0 = time.perf_counter()
|
| 61 |
+
transcript, rtf = stt.transcribe(
|
| 62 |
+
str(audio_path),
|
| 63 |
+
chunk_sec=args.chunk_sec,
|
| 64 |
+
overlap_sec=args.overlap_sec,
|
| 65 |
+
batch_size=args.batch_size
|
| 66 |
+
)
|
| 67 |
+
elapsed = time.perf_counter() - t0
|
| 68 |
+
|
| 69 |
+
print("\n" + "=" * 70)
|
| 70 |
+
print(f"✨ Transcription Completed in {elapsed:.2f}s (RTF: {rtf:.4f}, Speedup: {1/max(rtf, 1e-4):.1f}x)")
|
| 71 |
+
print("=" * 70)
|
| 72 |
+
print(transcript)
|
| 73 |
+
print("=" * 70)
|
| 74 |
+
|
| 75 |
+
if args.output:
|
| 76 |
+
out_p = Path(args.output)
|
| 77 |
+
with open(out_p, "w", encoding="utf-8") as f:
|
| 78 |
+
f.write(transcript)
|
| 79 |
+
print(f"💾 Transcript saved to: {out_p}")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
if __name__ == "__main__":
|
| 83 |
+
main()
|