yclee12345 commited on
Commit
36deb53
·
verified ·
1 Parent(s): d46f851

Initial release of Notesoop-STT-Ko-v1 (Private)

Browse files
.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
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  *.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>&nbsp;&nbsp;<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()