mrwine commited on
Commit
4f5f309
·
verified ·
1 Parent(s): 0c50dcd

Initial release: optimized CPU runtime and converted weights of maya2-native

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +3 -0
  2. NOTICE +10 -0
  3. README.md +127 -0
  4. assets/v1/audio_in_bf16.bin +3 -0
  5. assets/v1/backbone_wo8.data +3 -0
  6. assets/v1/backbone_wo8.onnx +3 -0
  7. assets/v1/controls.json +183 -0
  8. assets/v1/depth_frame_fp16.onnx +3 -0
  9. assets/v1/depth_frame_fp16.onnx.data +3 -0
  10. assets/v1/embed_bf16.bin +3 -0
  11. assets/v1/manifest.json +29 -0
  12. assets/v1/mimi_decoder.onnx +3 -0
  13. assets/v1/prompt.npz +3 -0
  14. assets/v1/tokenizer.json +3 -0
  15. assets/v1/voices.json +102 -0
  16. requirements-fast.txt +5 -0
  17. src/maya_fast/__init__.py +24 -0
  18. src/maya_fast/__main__.py +48 -0
  19. src/maya_fast/__pycache__/__init__.cpython-313.pyc +0 -0
  20. src/maya_fast/__pycache__/__main__.cpython-313.pyc +0 -0
  21. src/maya_fast/__pycache__/assets.cpython-313.pyc +0 -0
  22. src/maya_fast/__pycache__/backbone.cpython-313.pyc +0 -0
  23. src/maya_fast/__pycache__/codec.cpython-313.pyc +0 -0
  24. src/maya_fast/__pycache__/control.cpython-313.pyc +0 -0
  25. src/maya_fast/__pycache__/depth.cpython-313.pyc +0 -0
  26. src/maya_fast/__pycache__/embed.cpython-313.pyc +0 -0
  27. src/maya_fast/__pycache__/engine.cpython-313.pyc +0 -0
  28. src/maya_fast/__pycache__/ort_opts.cpython-313.pyc +0 -0
  29. src/maya_fast/__pycache__/prompt.cpython-313.pyc +0 -0
  30. src/maya_fast/__pycache__/sampling.cpython-313.pyc +0 -0
  31. src/maya_fast/assets.py +91 -0
  32. src/maya_fast/backbone.py +157 -0
  33. src/maya_fast/codec.py +34 -0
  34. src/maya_fast/control.py +135 -0
  35. src/maya_fast/depth.py +62 -0
  36. src/maya_fast/embed.py +25 -0
  37. src/maya_fast/engine.py +236 -0
  38. src/maya_fast/ort_opts.py +20 -0
  39. src/maya_fast/prompt.py +103 -0
  40. src/maya_fast/sampling.py +34 -0
  41. tests/__init__.py +0 -0
  42. tests/fixtures/__init__.py +0 -0
  43. tests/fixtures/tiny_assets.py +286 -0
  44. tests/test_audio_utils.py +76 -0
  45. tests/test_maya_fast_assets.py +51 -0
  46. tests/test_maya_fast_backbone.py +50 -0
  47. tests/test_maya_fast_codec.py +45 -0
  48. tests/test_maya_fast_control.py +123 -0
  49. tests/test_maya_fast_depth.py +41 -0
  50. tests/test_maya_fast_engine.py +95 -0
.gitattributes CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/v1/backbone_wo8.data filter=lfs diff=lfs merge=lfs -text
37
+ assets/v1/depth_frame_fp16.onnx.data filter=lfs diff=lfs merge=lfs -text
38
+ assets/v1/tokenizer.json filter=lfs diff=lfs merge=lfs -text
NOTICE ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ maya2-native-fast
2
+ ==================
3
+
4
+ Derived from maya-research/maya2-native (Maya Research), Apache-2.0.
5
+ Includes a Mimi decoder converted from kyutai/mimi (CC-BY-4.0).
6
+ Backbone initialised from Qwen3-1.7B-Base (Qwen team), Apache-2.0.
7
+ Speaker conditioning uses speechbrain/spkrec-ecapa-voxceleb (SpeechBrain), Apache-2.0.
8
+
9
+ Apache-2.0 full text: https://www.apache.org/licenses/LICENSE-2.0
10
+ CC-BY-4.0 full text: https://creativecommons.org/licenses/by/4.0/
README.md ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ base_model: maya-research/maya2-native
4
+ language:
5
+ - mr
6
+ - hi
7
+ library_name: onnx
8
+ pipeline_tag: text-to-speech
9
+ tags:
10
+ - text-to-speech
11
+ - onnx
12
+ - int8
13
+ - fp16
14
+ - marathi
15
+ - hindi
16
+ - cpu
17
+ ---
18
+
19
+ # maya2-native-fast
20
+
21
+ Optimized CPU inference for [maya-research/maya2-native](https://huggingface.co/maya-research/maya2-native):
22
+ ONNX graphs, int8 backbone, fp16 depth, a torch-free numpy runtime.
23
+
24
+ **This is not a fine-tune.** The weights are converted from the upstream checkpoint. No training data was used.
25
+
26
+ ## What is in the repo
27
+
28
+ | Path | Contents |
29
+ |---|---|
30
+ | `assets/v1/backbone_wo8.onnx` + `.data` | 28-layer Qwen3 backbone, int8 weights (MatMulNBits, block 32), 1.6 GB |
31
+ | `assets/v1/depth_frame_fp16.onnx` + `.data` | Depth sampler, 15 levels, fp16 storage, Gumbel top-k sampling in-graph, 873 MB |
32
+ | `assets/v1/mimi_decoder.onnx` | Mimi decoder, fp32, dynamic frame count, 195 MB |
33
+ | `assets/v1/embed_bf16.bin`, `audio_in_bf16.bin` | Text and audio embedding tables, bf16, memory-mapped |
34
+ | `assets/v1/prompt.npz`, `tokenizer.json`, `voices.json`, `controls.json` | Prompt weights, extended tokenizer, voices, sentence controls |
35
+ | `assets/v1/manifest.json` | sha256 of every file above, runtime shapes. The runtime refuses mismatches. |
36
+ | `src/maya_fast/` | Runtime (numpy, onnxruntime, tokenizers; no torch) |
37
+ | `tools/maya_export/` | Export and evaluation scripts, to rebuild the assets from the upstream checkpoint |
38
+ | `tests/` | Runtime and export tests |
39
+
40
+ ## Measured results (Apple M-series Mac, CPU, 8 threads)
41
+
42
+ | Check | Result | Target | Status |
43
+ |---|---|---|---|
44
+ | Depth fp16 vs fp32 reference, teacher-forced, 75 files | top-1 >= 0.9965 every level, KL <= 6e-6 | top-1 >= 0.98, KL <= 0.02 | pass |
45
+ | Depth fp32 vs upstream PyTorch, 20 frames | rel 1.6e-6 | rel <= 1e-4 | pass |
46
+ | Mimi decoder vs upstream | max abs 1.5e-6 | <= 1e-4 | pass |
47
+ | Prompt port vs upstream, 500 prompts | 0 token mismatches, max embed error 7.5e-9 | <= 1e-5 | pass |
48
+ | Backbone int8 vs fp32, teacher-forced | top-1 0.9677, KL 0.0034 | top-1 >= 0.99 | **fail (waived)** |
49
+ | Backbone speed, 8 threads | 48 / 58 / 82 ms per token at 32 / 256 / 768 cached tokens | <= 35 ms at 256 | **fail (waived)** |
50
+ | End-to-end, one Marathi sentence (2.96 s audio), two runs | RTF 4.67 and 4.43 | offline RTF <= 4 | **fail** |
51
+ | Peak RSS, end-to-end | 3.9 GB | <= 3 GB target, 5 GB limit | over target |
52
+
53
+ Known limits:
54
+ - The int8 backbone misses top-1 agreement; expect small differences in generated codes.
55
+ - End-to-end RTF on this machine is above the offline target.
56
+ - Hindi (`--lang hi`) works mechanically; it has not been checked for quality.
57
+ - Not yet checked by listening tests.
58
+ - Real-time playback is not supported.
59
+
60
+ ## Install
61
+
62
+ ```bash
63
+ python -m venv .venv
64
+ .venv/bin/pip install -r requirements-fast.txt
65
+ ```
66
+
67
+ Requires Python 3.10 to 3.13. Peak memory is about 4 GB while a sentence is synthesized.
68
+
69
+ ## Use: command line
70
+
71
+ Run from the repo root (so `src` is importable):
72
+
73
+ ```bash
74
+ PYTHONPATH=. .venv/bin/python -m src.maya_fast "नमस्कार, मी तुमची मदत करण्यासाठी इथे आहे." \
75
+ --assets assets/v1 -o out.wav
76
+ ```
77
+
78
+ Options:
79
+ - `--voice NAME`: named voice from `assets/v1/voices.json` (case-insensitive). Default `Charvi`.
80
+ - `--lang mr|hi`: language tag. Default `mr`.
81
+ - `--seed N`: random seed. Same seed and threads give the same audio.
82
+ - `--threads N`: ORT threads. Default min(8, cores).
83
+
84
+ ## Use: Python
85
+
86
+ ```python
87
+ from src.maya_fast.engine import MayaFast
88
+
89
+ engine = MayaFast(assets_dir="assets/v1", threads=8)
90
+ wav, metrics = engine.synthesize("नमस्कार, मी तुमची मदत करण्यासाठी इथे आहे.", voice="Charvi", seed=0, lang="mr")
91
+ # wav: float32, 24 kHz mono
92
+ ```
93
+
94
+ Write it with `soundfile` or the standard `wave` module.
95
+
96
+ Assets are verified against `manifest.json` at load. A bad or partial download fails with the file name.
97
+
98
+ ## Rebuild the assets from upstream (optional)
99
+
100
+ Needs the upstream checkpoint (`maya-research/maya2-native`, `kyutai/mimi`, `speechbrain/spkrec-ecapa-voxceleb`) and an upstream `maya3` install in a second venv (`.venv-tts`, torch 2.8, transformers 5.6.2). The export scripts write to `$MAYA_FAST_WORK/assets/v1`, default `~/.cache/maya_fast`. See `tools/maya_export/`:
101
+ 1. `export_prompt_assets.py`, `export_backbone.py --precision wo8`, `export_depth_all.py`, `export_codec.py`
102
+ 2. `finalize_assets.py` writes the runtime manifest.
103
+ Run each step under `tools/memguard.py`, which kills the process above a memory limit.
104
+
105
+ ## License and attribution
106
+
107
+ - These weights and code are derived from Maya2 Native (Apache-2.0) by Maya Research.
108
+ - The Mimi decoder is derived from Kyutai Mimi (CC-BY-4.0). Keep this attribution.
109
+ - The text backbone is derived from Qwen3-1.7B-Base (Apache-2.0).
110
+ - Speaker conditioning uses SpeechBrain ECAPA-TDNN (Apache-2.0).
111
+ - Full notices in `NOTICE`.
112
+
113
+ The model adds no audio watermark.
114
+
115
+ ## Citation
116
+
117
+ Please cite the upstream model:
118
+
119
+ ```bibtex
120
+ @misc{maya2native2026,
121
+ title = {Maya 2 Native: Streaming Multilingual Text-to-Speech},
122
+ author = {{Maya Research}},
123
+ year = {2026},
124
+ url = {https://huggingface.co/maya-research/maya2-native},
125
+ note = {Release 1.0}
126
+ }
127
+ ```
assets/v1/audio_in_bf16.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2670e5583c2d2e6e963a8117136a8d07e0e892d3ffda2620d3f13cdbaddc94d0
3
+ size 268435456
assets/v1/backbone_wo8.data ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ce9ebfb1e911bac4e3bc58b77c6657f75b79f145f89df718f23d352d824dcb01
3
+ size 1602727936
assets/v1/backbone_wo8.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d0326c5973d9d71708d7c39c3f34851c73addc88bceb46d0ac90c37e9041955d
3
+ size 1121189
assets/v1/controls.json ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "text_frames_prior": {
3
+ "as": {
4
+ "a": -1.36,
5
+ "b": 0.7786,
6
+ "resid_p99": 91.6
7
+ },
8
+ "bn": {
9
+ "a": 3.16,
10
+ "b": 0.7887,
11
+ "resid_p99": 76.2
12
+ },
13
+ "en": {
14
+ "a": 0.6,
15
+ "b": 0.7416,
16
+ "resid_p99": 75.0
17
+ },
18
+ "gu": {
19
+ "a": 1.74,
20
+ "b": 0.8834,
21
+ "resid_p99": 83.1
22
+ },
23
+ "hi": {
24
+ "a": 5.65,
25
+ "b": 0.7644,
26
+ "resid_p99": 79.8
27
+ },
28
+ "kn": {
29
+ "a": 1.91,
30
+ "b": 0.7675,
31
+ "resid_p99": 69.5
32
+ },
33
+ "ml": {
34
+ "a": 1.25,
35
+ "b": 0.6788,
36
+ "resid_p99": 105.6
37
+ },
38
+ "mr": {
39
+ "a": 1.12,
40
+ "b": 0.8235,
41
+ "resid_p99": 60.1
42
+ },
43
+ "or": {
44
+ "a": 1.06,
45
+ "b": 0.7936,
46
+ "resid_p99": 68.4
47
+ },
48
+ "pa": {
49
+ "a": 1.86,
50
+ "b": 0.8079,
51
+ "resid_p99": 73.5
52
+ },
53
+ "ta": {
54
+ "a": 2.24,
55
+ "b": 0.6425,
56
+ "resid_p99": 70.8
57
+ },
58
+ "te": {
59
+ "a": 1.2,
60
+ "b": 0.7927,
61
+ "resid_p99": 89.6
62
+ }
63
+ },
64
+ "prior_default": {
65
+ "a": 2.0,
66
+ "b": 0.72,
67
+ "resid_p99": 90.0
68
+ },
69
+ "text": {
70
+ "terminals": "।.!?;।॥",
71
+ "fps": 12.5,
72
+ "frame_s": 0.08,
73
+ "chunk_s": 16.0
74
+ },
75
+ "guard": {
76
+ "enabled": true,
77
+ "dur_k": 2.2,
78
+ "dur_margin_s": 1.2,
79
+ "hard_cap": 1500,
80
+ "dur_prior_coefs": {
81
+ "as": [
82
+ 0.0,
83
+ 0.07962,
84
+ 0.17764,
85
+ 0.53121,
86
+ 0.62254
87
+ ],
88
+ "bn": [
89
+ 0.71369,
90
+ 0.06604,
91
+ 0.04537,
92
+ 0.49869,
93
+ 0.23288
94
+ ],
95
+ "en": [
96
+ 2.87006,
97
+ 0.01,
98
+ 0.04601,
99
+ 0.40336,
100
+ 0.06541
101
+ ],
102
+ "gu": [
103
+ 0.54378,
104
+ 0.07044,
105
+ 0.01,
106
+ 0.50786,
107
+ 0.24873
108
+ ],
109
+ "hi": [
110
+ 2.44169,
111
+ 0.05329,
112
+ 0.02187,
113
+ 0.43341,
114
+ 0.37052
115
+ ],
116
+ "kn": [
117
+ 1.71201,
118
+ 0.06651,
119
+ 0.01,
120
+ 0.45228,
121
+ 0.5053
122
+ ],
123
+ "ml": [
124
+ 0.92428,
125
+ 0.05706,
126
+ 0.11842,
127
+ 0.52651,
128
+ 0.41106
129
+ ],
130
+ "mr": [
131
+ 1.44052,
132
+ 0.0719,
133
+ 0.03206,
134
+ 0.30526,
135
+ 0.28705
136
+ ],
137
+ "or": [
138
+ 0.0,
139
+ 0.08134,
140
+ 0.12578,
141
+ 0.43209,
142
+ 0.23588
143
+ ],
144
+ "pa": [
145
+ 0.57673,
146
+ 0.07431,
147
+ 0.01,
148
+ 0.39355,
149
+ 0.37283
150
+ ],
151
+ "ta": [
152
+ 2.05273,
153
+ 0.05244,
154
+ 0.01,
155
+ 0.47524,
156
+ 0.45486
157
+ ],
158
+ "te": [
159
+ 0.45026,
160
+ 0.07269,
161
+ 0.0576,
162
+ 0.46093,
163
+ 0.39692
164
+ ]
165
+ },
166
+ "dur_prior_default": [
167
+ 1.1438125,
168
+ 0.06297000000000001,
169
+ 0.05539583333333333,
170
+ 0.45169916666666676,
171
+ 0.35033166666666665
172
+ ]
173
+ },
174
+ "sampling": {
175
+ "q_serve": 16,
176
+ "temp": 0.7,
177
+ "top_k": 64,
178
+ "tau": 0.35,
179
+ "hazard": 0.6,
180
+ "cfg_scale": 0.0,
181
+ "aeos_class": 2048
182
+ }
183
+ }
assets/v1/depth_frame_fp16.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c49a4db17d5f443371c459d75c4bdbd508abc164ba30caf51f70c36032c1cea4
3
+ size 4507646
assets/v1/depth_frame_fp16.onnx.data ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:494c8e441065164eb1aa26100f5c2763b447108a116914036a72e1b124463e61
3
+ size 872850432
assets/v1/embed_bf16.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7940ea16f0a59b955ac42a9f5c035930e0aded3e034ca68de18531c45a6ff861
3
+ size 796868608
assets/v1/manifest.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": 1,
3
+ "files": {
4
+ "tokenizer.json": "26c51f9b772f9084ed6bdeabc44b7c15397c12966bfa3e1c09b025e82f4fdbaf",
5
+ "embed_bf16.bin": "7940ea16f0a59b955ac42a9f5c035930e0aded3e034ca68de18531c45a6ff861",
6
+ "audio_in_bf16.bin": "2670e5583c2d2e6e963a8117136a8d07e0e892d3ffda2620d3f13cdbaddc94d0",
7
+ "prompt.npz": "0d6392e0cba6009a913ba57605641b48b1a1fa32a0040ffd4b7b2f840be8afc5",
8
+ "voices.json": "dac72e40ff1c3863f91ab65d99346965df98967cc40b2a8234b757ae7892b4cf",
9
+ "controls.json": "527f1e3129bf6b16df9ac7e2ec7dc813da6b74a207031cc41d959b6a6337021e",
10
+ "backbone_wo8.onnx": "d0326c5973d9d71708d7c39c3f34851c73addc88bceb46d0ac90c37e9041955d",
11
+ "backbone_wo8.data": "ce9ebfb1e911bac4e3bc58b77c6657f75b79f145f89df718f23d352d824dcb01",
12
+ "depth_frame_fp16.onnx": "c49a4db17d5f443371c459d75c4bdbd508abc164ba30caf51f70c36032c1cea4",
13
+ "depth_frame_fp16.onnx.data": "494c8e441065164eb1aa26100f5c2763b447108a116914036a72e1b124463e61",
14
+ "mimi_decoder.onnx": "a0e6accff030071e49c1e1824b26f3a4868c39d125f11bb8079b4a4bbd9dcb58"
15
+ },
16
+ "shapes": {
17
+ "d_model": 2048,
18
+ "vocab": 194548,
19
+ "audio_rows": 65536,
20
+ "n_q": 16,
21
+ "n_codes": 2048,
22
+ "ecapa_dim": 192,
23
+ "rope_theta": 1000000.0,
24
+ "n_layers": 28,
25
+ "n_heads": 8,
26
+ "head_dim": 128,
27
+ "d_mem": 1024
28
+ }
29
+ }
assets/v1/mimi_decoder.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a0e6accff030071e49c1e1824b26f3a4868c39d125f11bb8079b4a4bbd9dcb58
3
+ size 194722647
assets/v1/prompt.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0d6392e0cba6009a913ba57605641b48b1a1fa32a0040ffd4b7b2f840be8afc5
3
+ size 4616734
assets/v1/tokenizer.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:26c51f9b772f9084ed6bdeabc44b7c15397c12966bfa3e1c09b025e82f4fdbaf
3
+ size 16103051
assets/v1/voices.json ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "Ahana": {
3
+ "mode": "named",
4
+ "use_icl": false,
5
+ "use_mem": false
6
+ },
7
+ "Vaidehi": {
8
+ "mode": "named",
9
+ "use_icl": false,
10
+ "use_mem": false
11
+ },
12
+ "Malini": {
13
+ "mode": "named",
14
+ "use_icl": false,
15
+ "use_mem": false
16
+ },
17
+ "Charvi": {
18
+ "mode": "named",
19
+ "use_icl": false,
20
+ "use_mem": false
21
+ },
22
+ "Jahnavi": {
23
+ "mode": "named",
24
+ "use_icl": false,
25
+ "use_mem": false
26
+ },
27
+ "Anirban": {
28
+ "mode": "named",
29
+ "use_icl": false,
30
+ "use_mem": false
31
+ },
32
+ "Nakul": {
33
+ "mode": "named",
34
+ "use_icl": false,
35
+ "use_mem": false
36
+ },
37
+ "Vasant": {
38
+ "mode": "named",
39
+ "use_icl": false,
40
+ "use_mem": false
41
+ },
42
+ "Siddhant": {
43
+ "mode": "named",
44
+ "use_icl": false,
45
+ "use_mem": false
46
+ },
47
+ "Satvik": {
48
+ "mode": "named",
49
+ "use_icl": false,
50
+ "use_mem": false
51
+ },
52
+ "Abhay": {
53
+ "mode": "named",
54
+ "use_icl": false,
55
+ "use_mem": false
56
+ },
57
+ "Nivedita": {
58
+ "mode": "named",
59
+ "use_icl": false,
60
+ "use_mem": false
61
+ },
62
+ "Ishwari": {
63
+ "mode": "named",
64
+ "use_icl": false,
65
+ "use_mem": false
66
+ },
67
+ "Ritvik": {
68
+ "mode": "named",
69
+ "use_icl": false,
70
+ "use_mem": false
71
+ },
72
+ "Vrinda": {
73
+ "mode": "named",
74
+ "use_icl": false,
75
+ "use_mem": false
76
+ },
77
+ "Sreekar": {
78
+ "mode": "named",
79
+ "use_icl": false,
80
+ "use_mem": false
81
+ },
82
+ "Kayal": {
83
+ "mode": "named",
84
+ "use_icl": false,
85
+ "use_mem": false
86
+ },
87
+ "Shaunak": {
88
+ "mode": "named",
89
+ "use_icl": false,
90
+ "use_mem": false
91
+ },
92
+ "Akshat": {
93
+ "mode": "named",
94
+ "use_icl": false,
95
+ "use_mem": false
96
+ },
97
+ "Tanushree": {
98
+ "mode": "named",
99
+ "use_icl": false,
100
+ "use_mem": false
101
+ }
102
+ }
requirements-fast.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ numpy>=2.0
2
+ onnxruntime==1.31.*
3
+ tokenizers>=0.22,<0.23
4
+ psutil
5
+ onnx
src/maya_fast/__init__.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from .engine import MayaFast
4
+ from .assets import PromptAssets, load_manifest, AssetError
5
+ from .backbone import Backbone
6
+ from .depth import Depth
7
+ from .codec import MimiDecoder
8
+ from .prompt import AudioIn, Prompt, build_prompt
9
+ from .control import StopPolicy, sample_cb0
10
+
11
+ __all__ = [
12
+ "MayaFast",
13
+ "PromptAssets",
14
+ "load_manifest",
15
+ "AssetError",
16
+ "Backbone",
17
+ "Depth",
18
+ "MimiDecoder",
19
+ "AudioIn",
20
+ "Prompt",
21
+ "build_prompt",
22
+ "StopPolicy",
23
+ "sample_cb0",
24
+ ]
src/maya_fast/__main__.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ import wave
6
+ from pathlib import Path
7
+ import numpy as np
8
+
9
+ from .engine import MayaFast
10
+
11
+
12
+ def main() -> None:
13
+ parser = argparse.ArgumentParser(description="Maya-Fast TTS CLI")
14
+ parser.add_argument("text", type=str, help="Text to speak")
15
+ parser.add_argument("-o", "--out", type=str, default="out.wav", help="Output WAV path")
16
+ parser.add_argument("--voice", type=str, default="Charvi", help="Voice name (default: Charvi)")
17
+ parser.add_argument("--seed", type=int, default=0, help="Random seed (default: 0)")
18
+ parser.add_argument("--lang", type=str, default="mr", help="Language tag: mr, hi, ... (default: mr)")
19
+ parser.add_argument("--threads", type=int, default=None, help="ORT threads (default: min(8, cores))")
20
+ parser.add_argument("--assets", type=str, default=None, help="Assets directory")
21
+
22
+ args = parser.parse_args()
23
+
24
+ engine = MayaFast(assets_dir=args.assets, threads=args.threads)
25
+ wav, metrics = engine.synthesize(args.text, voice=args.voice, seed=args.seed, lang=args.lang)
26
+
27
+ # Convert float32 [-1.0, 1.0] to 16-bit PCM WAV using stdlib wave
28
+ wav_clipped = np.clip(wav, -1.0, 1.0)
29
+ pcm16 = (wav_clipped * 32767.0).astype(np.int16)
30
+
31
+ out_path = Path(args.out)
32
+ out_path.parent.mkdir(parents=True, exist_ok=True)
33
+ with wave.open(str(out_path), "wb") as wf:
34
+ wf.setnchannels(1)
35
+ wf.setsampwidth(2)
36
+ wf.setframerate(24000)
37
+ wf.writeframes(pcm16.tobytes())
38
+
39
+ print(
40
+ f"audio_s={metrics['audio_s']:.2f} "
41
+ f"wall_s={metrics['wall_s']:.2f} "
42
+ f"rtf={metrics['rtf']:.2f} "
43
+ f"peak_rss_mb={metrics['peak_rss_mb']:.1f}"
44
+ )
45
+
46
+
47
+ if __name__ == "__main__":
48
+ main()
src/maya_fast/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (721 Bytes). View file
 
src/maya_fast/__pycache__/__main__.cpython-313.pyc ADDED
Binary file (3.08 kB). View file
 
src/maya_fast/__pycache__/assets.cpython-313.pyc ADDED
Binary file (5.74 kB). View file
 
src/maya_fast/__pycache__/backbone.cpython-313.pyc ADDED
Binary file (8.59 kB). View file
 
src/maya_fast/__pycache__/codec.cpython-313.pyc ADDED
Binary file (2.1 kB). View file
 
src/maya_fast/__pycache__/control.cpython-313.pyc ADDED
Binary file (8.36 kB). View file
 
src/maya_fast/__pycache__/depth.cpython-313.pyc ADDED
Binary file (2.92 kB). View file
 
src/maya_fast/__pycache__/embed.cpython-313.pyc ADDED
Binary file (1.94 kB). View file
 
src/maya_fast/__pycache__/engine.cpython-313.pyc ADDED
Binary file (10.2 kB). View file
 
src/maya_fast/__pycache__/ort_opts.cpython-313.pyc ADDED
Binary file (1.13 kB). View file
 
src/maya_fast/__pycache__/prompt.cpython-313.pyc ADDED
Binary file (7.62 kB). View file
 
src/maya_fast/__pycache__/sampling.cpython-313.pyc ADDED
Binary file (2.07 kB). View file
 
src/maya_fast/assets.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any
8
+ import numpy as np
9
+
10
+
11
+ class AssetError(Exception):
12
+ pass
13
+
14
+
15
+ def default_work_dir() -> Path:
16
+ env = os.environ.get("MAYA_FAST_WORK")
17
+ if env:
18
+ return Path(env)
19
+ return Path.home() / ".cache" / "maya_fast"
20
+
21
+
22
+ def default_assets_dir() -> Path:
23
+ return default_work_dir() / "assets" / "v1"
24
+
25
+
26
+ def compute_file_sha256(path: Path) -> str:
27
+ h = hashlib.sha256()
28
+ with open(path, "rb") as f:
29
+ while chunk := f.read(65536):
30
+ h.update(chunk)
31
+ return h.hexdigest()
32
+
33
+
34
+ def load_manifest(assets_dir: str | Path, verify: bool = True) -> dict[str, Any]:
35
+ assets_dir = Path(assets_dir)
36
+ manifest_file = assets_dir / "manifest.json"
37
+ if not manifest_file.exists():
38
+ raise AssetError(f"manifest.json missing in {assets_dir}")
39
+
40
+ with open(manifest_file, "r", encoding="utf-8") as f:
41
+ try:
42
+ manifest = json.load(f)
43
+ except Exception as e:
44
+ raise AssetError(f"Corrupt manifest.json in {assets_dir}: {e}")
45
+
46
+ if verify:
47
+ files = manifest.get("files", {})
48
+ for name, expected_sha in files.items():
49
+ fpath = assets_dir / name
50
+ if not fpath.exists():
51
+ raise AssetError(f"Required asset file missing: {name}")
52
+ actual_sha = compute_file_sha256(fpath)
53
+ if actual_sha != expected_sha:
54
+ raise AssetError(f"Asset sha256 mismatch for file: {name}")
55
+
56
+ return manifest
57
+
58
+
59
+ class PromptAssets:
60
+ def __init__(self, assets_dir: str | Path, verify: bool = True) -> None:
61
+ self.assets_dir = Path(assets_dir)
62
+ self.manifest = load_manifest(self.assets_dir, verify=verify)
63
+ self.shapes = self.manifest.get("shapes", {})
64
+
65
+ from .embed import EmbeddingTable
66
+ from .prompt import AudioIn
67
+
68
+ d_model = self.shapes.get("d_model", 2048)
69
+ vocab = self.shapes.get("vocab", 151936)
70
+ n_q = self.shapes.get("n_q", 16)
71
+ n_codes = self.shapes.get("n_codes", 2048)
72
+ self.embed_table = EmbeddingTable(self.assets_dir / "embed_bf16.bin", rows=vocab, dim=d_model)
73
+ audio_table = EmbeddingTable(self.assets_dir / "audio_in_bf16.bin", rows=n_q * n_codes, dim=d_model)
74
+
75
+ # Controls (text_frames_prior, guard, sampling, text)
76
+ with open(self.assets_dir / "controls.json", "r", encoding="utf-8") as f:
77
+ self.controls = json.load(f)
78
+
79
+ # Speaker anchor weights and audio input norm/aeos (fp32; the audio table is bf16 on disk)
80
+ prompt_npz = np.load(self.assets_dir / "prompt.npz")
81
+ self.prompt_params = {k: prompt_npz[k] for k in prompt_npz.files}
82
+ self.audio_in = AudioIn(audio_table, self.prompt_params["audio_in/aeos_in"],
83
+ self.prompt_params["audio_in/norm"], n_q=n_q)
84
+
85
+ # Voices: name -> {mode, use_icl, use_mem}
86
+ with open(self.assets_dir / "voices.json", "r", encoding="utf-8") as f:
87
+ self.voices_info = json.load(f)
88
+
89
+ # Tokenizer (extended upstream tokenizer, fast format)
90
+ from tokenizers import Tokenizer
91
+ self.tokenizer = Tokenizer.from_file(str(self.assets_dir / "tokenizer.json"))
src/maya_fast/backbone.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ import numpy as np
6
+ import onnxruntime as ort
7
+
8
+ from .ort_opts import session_options
9
+
10
+
11
+ class Backbone:
12
+ def __init__(
13
+ self,
14
+ path: str | Path,
15
+ threads: int | None = None,
16
+ manifest_path: str | Path | None = None,
17
+ config: dict | None = None,
18
+ ) -> None:
19
+ self.path = Path(path)
20
+ self.opts = session_options(threads)
21
+ self.session = ort.InferenceSession(str(self.path), self.opts, providers=["CPUExecutionProvider"])
22
+
23
+ # Determine shapes and hyperparameters
24
+ # Default real Qwen3 / Maya2 backbone parameters:
25
+ # n_layers=28, n_heads=8 (kv heads), head_dim=128, rope_theta=10000.0, d_model=2048
26
+ self.n_layers = 28
27
+ self.n_heads = 8
28
+ self.head_dim = 128
29
+ self.rope_theta = 10000.0
30
+ self.d_model = 2048
31
+
32
+ # If manifest or config provided, override:
33
+ if config is not None:
34
+ self._apply_config(config)
35
+ elif manifest_path is not None and Path(manifest_path).exists():
36
+ with open(manifest_path, "r", encoding="utf-8") as f:
37
+ data = json.load(f)
38
+ shapes = data.get("shapes", {})
39
+ self._apply_config(shapes)
40
+ else:
41
+ # Check if manifest.json exists next to model
42
+ maybe_manifest = self.path.parent / "manifest.json"
43
+ if maybe_manifest.exists():
44
+ with open(maybe_manifest, "r", encoding="utf-8") as f:
45
+ data = json.load(f)
46
+ shapes = data.get("shapes", {})
47
+ self._apply_config(shapes)
48
+ else:
49
+ # Discover n_layers from session inputs
50
+ past_k_inputs = [inp.name for inp in self.session.get_inputs() if inp.name.startswith("past_k_")]
51
+ if past_k_inputs:
52
+ self.n_layers = len(past_k_inputs)
53
+
54
+ # Inspect session input shapes if available
55
+ for inp in self.session.get_inputs():
56
+ if inp.name == "past_k_0":
57
+ shape = inp.shape
58
+ # shape: [1, n_heads, P, head_dim]
59
+ if len(shape) == 4:
60
+ if isinstance(shape[1], int):
61
+ self.n_heads = shape[1]
62
+ if isinstance(shape[3], int):
63
+ self.head_dim = shape[3]
64
+ elif inp.name == "embeds":
65
+ if len(inp.shape) == 3 and isinstance(inp.shape[2], int):
66
+ self.d_model = inp.shape[2]
67
+
68
+ self.reset()
69
+
70
+ def _apply_config(self, cfg: dict) -> None:
71
+ if "n_layers" in cfg:
72
+ self.n_layers = int(cfg["n_layers"])
73
+ if "n_heads" in cfg:
74
+ self.n_heads = int(cfg["n_heads"])
75
+ if "head_dim" in cfg:
76
+ self.head_dim = int(cfg["head_dim"])
77
+ if "rope_theta" in cfg:
78
+ self.rope_theta = float(cfg["rope_theta"])
79
+ if "d_model" in cfg:
80
+ self.d_model = int(cfg["d_model"])
81
+
82
+ def reset(self) -> None:
83
+ self.past_len = 0
84
+ self.past_k = [np.zeros((1, self.n_heads, 0, self.head_dim), dtype=np.float32) for _ in range(self.n_layers)]
85
+ self.past_v = [np.zeros((1, self.n_heads, 0, self.head_dim), dtype=np.float32) for _ in range(self.n_layers)]
86
+
87
+ def _compute_rope(self, T: int, past: int) -> tuple[np.ndarray, np.ndarray]:
88
+ # RoPE for positions [past, past + T)
89
+ # dim = head_dim
90
+ pos = np.arange(past, past + T, dtype=np.float32)[:, None] # (T, 1)
91
+ dim_indices = np.arange(0, self.head_dim, 2, dtype=np.float32) # (head_dim/2,)
92
+ inv_freq = 1.0 / (self.rope_theta ** (dim_indices / self.head_dim)) # (head_dim/2,)
93
+ sinusoid = pos * inv_freq[None, :] # (T, head_dim/2)
94
+ # duplicate across second half to match typical rotary implementation: [sinusoid, sinusoid]
95
+ sinusoid = np.concatenate([sinusoid, sinusoid], axis=-1) # (T, head_dim)
96
+ cos = np.cos(sinusoid)[None, :, :] # (1, T, head_dim)
97
+ sin = np.sin(sinusoid)[None, :, :] # (1, T, head_dim)
98
+ return cos.astype(np.float32), sin.astype(np.float32)
99
+
100
+ def _compute_mask(self, T: int, past: int) -> np.ndarray:
101
+ # Additive causal mask (1, 1, T, past+T): query i sees keys j <= past + i
102
+ total_len = past + T
103
+ i = np.arange(T)[:, None]
104
+ j = np.arange(total_len)[None, :]
105
+ mask = np.zeros((T, total_len), dtype=np.float32)
106
+ mask[j > (past + i)] = -np.inf
107
+ return mask[None, None, :, :]
108
+
109
+ def step(self, embeds: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
110
+ """
111
+ embeds: (T, d_model) or (1, T, d_model)
112
+ Returns:
113
+ hidden: (d_model,) last position
114
+ cb0_logits: (vocab + 1,) last position
115
+ """
116
+ if embeds.ndim == 2:
117
+ embeds = embeds[None, :, :]
118
+ elif embeds.ndim != 3:
119
+ raise ValueError(f"Expected embeds to have 2 or 3 dims, got shape {embeds.shape}")
120
+
121
+ embeds = embeds.astype(np.float32)
122
+ T = embeds.shape[1]
123
+ P = self.past_len
124
+
125
+ cos, sin = self._compute_rope(T, P)
126
+ mask = self._compute_mask(T, P)
127
+
128
+ feed = {
129
+ "embeds": embeds,
130
+ "cos": cos,
131
+ "sin": sin,
132
+ "mask": mask,
133
+ }
134
+ for i in range(self.n_layers):
135
+ feed[f"past_k_{i}"] = self.past_k[i]
136
+ feed[f"past_v_{i}"] = self.past_v[i]
137
+
138
+ outputs = self.session.run(None, feed)
139
+
140
+ # Output ordering from session:
141
+ # output[0] = hidden (1, T, d_model)
142
+ # output[1] = cb0_logits (1, T, vocab + 1)
143
+ # output[2..] = present_k_i, present_v_i
144
+ hidden_out = outputs[0]
145
+ cb0_logits_out = outputs[1]
146
+
147
+ idx = 2
148
+ for i in range(self.n_layers):
149
+ self.past_k[i] = outputs[idx]
150
+ self.past_v[i] = outputs[idx + 1]
151
+ idx += 2
152
+
153
+ self.past_len = P + T
154
+
155
+ last_hidden = hidden_out[0, -1, :]
156
+ last_cb0_logits = cb0_logits_out[0, -1, :]
157
+ return last_hidden, last_cb0_logits
src/maya_fast/codec.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import numpy as np
5
+ import onnxruntime as ort
6
+
7
+ from .ort_opts import session_options
8
+
9
+
10
+ class MimiDecoder:
11
+ def __init__(self, path: str | Path, threads: int | None = None) -> None:
12
+ self.path = Path(path)
13
+ self.opts = session_options(threads)
14
+ self.session = ort.InferenceSession(str(self.path), self.opts, providers=["CPUExecutionProvider"])
15
+
16
+ def decode(self, codes: np.ndarray) -> np.ndarray:
17
+ """
18
+ Input:
19
+ codes: (16, F) int64
20
+ Returns:
21
+ wav: (F * 1920,) float32
22
+ """
23
+ codes = np.asarray(codes, dtype=np.int64)
24
+ if codes.ndim == 2:
25
+ codes_input = codes[None, :, :] # (1, 16, F)
26
+ elif codes.ndim == 3:
27
+ codes_input = codes
28
+ else:
29
+ raise ValueError(f"Expected codes to have 2 or 3 dims, got {codes.shape}")
30
+
31
+ outputs = self.session.run(None, {"codes": codes_input})
32
+ wav = outputs[0] # (1, 1, samples)
33
+ wav = np.squeeze(wav)
34
+ return wav.astype(np.float32)
src/maya_fast/control.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ import numpy as np
6
+
7
+ # Port of maya3.guards (DurationPrior, text_feats) and scripts/s2_serve_controls.py (expected_frames, split_text).
8
+ # The numbers come from controls.json, written by tools/maya_export/export_prompt_assets.py from upstream.
9
+ PAUSE = re.compile(r"[,;:.!?।॥…\-]")
10
+ PRIOR_DEFAULT = {"a": 2.0, "b": 0.72, "resid_p99": 90.0} # scripts/s2_serve_controls.py
11
+ FRAME_S = 0.08 # maya3.guards.FRAME_S (one frame = 80 ms of audio)
12
+
13
+
14
+ def text_feats(t: str) -> np.ndarray:
15
+ letters = sum(1 for ch in t if ch.isalpha() or (0x900 <= ord(ch) <= 0xDFF))
16
+ latin = sum(1 for ch in t if ch.isascii() and ch.isalpha())
17
+ return np.array([1.0, letters - latin, latin, len(PAUSE.findall(t)), sum(ch.isdigit() for ch in t)])
18
+
19
+
20
+ def expected_frames(prior: dict, lang: str, nchar: int) -> tuple[float, float]:
21
+ p = prior.get(lang, PRIOR_DEFAULT)
22
+ return p["a"] + p["b"] * nchar, p["resid_p99"]
23
+
24
+
25
+ def min_frames(ef: float) -> int:
26
+ return max(4, int(0.5 * ef))
27
+
28
+
29
+ def expected_seconds(text: str, lang: str, guard: dict) -> float:
30
+ coefs = guard["dur_prior_coefs"]
31
+ coef = np.asarray(coefs.get(lang, guard["dur_prior_default"]), dtype=np.float64)
32
+ return float(max(0.35, text_feats(text) @ coef))
33
+
34
+
35
+ def frame_cap(text: str, lang: str, ef: float, er: float, controls: dict) -> int:
36
+ """Duration cap as in tools/maya_export/golden_light.py: min(loose, max(0.5*ef + 8, duration-prior cap))."""
37
+ guard = controls["guard"]
38
+ loose = min(int(guard["hard_cap"]), int(2.0 * (ef + er) + 40))
39
+ if not guard["enabled"]:
40
+ return loose
41
+ dur_cap = int((float(guard["dur_k"]) * expected_seconds(text, lang, guard)
42
+ + float(guard["dur_margin_s"])) / FRAME_S)
43
+ return int(min(loose, max(int(ef * 0.5) + 8, dur_cap)))
44
+
45
+
46
+ def split_text(text: str, lang: str, prior: dict, chunk_s: float = 12.0, *, fps: float = 12.5,
47
+ terminals: str = "।.!?;।॥") -> list[str]:
48
+ """Split at sentence terminals into pieces of about chunk_s seconds (prior-estimated).
49
+ Falls back to commas, then whitespace, for run-on text."""
50
+ if chunk_s <= 0:
51
+ return [text]
52
+ target = chunk_s * fps
53
+ pieces = [p.strip() for p in re.split(rf"(?<=[{re.escape(terminals)}])\s+", text) if p.strip()]
54
+ out: list[str] = []
55
+ cur = ""
56
+ for p in pieces:
57
+ cand = (cur + " " + p).strip() if cur else p
58
+ if cur and expected_frames(prior, lang, len(cand))[0] > target:
59
+ out.append(cur)
60
+ cur = p
61
+ else:
62
+ cur = cand
63
+ if cur:
64
+ out.append(cur)
65
+
66
+ final: list[str] = []
67
+ for piece in out:
68
+ if expected_frames(prior, lang, len(piece))[0] <= target * 1.3:
69
+ final.append(piece)
70
+ continue
71
+ words = re.split(r"(?<=[,،])\s+|\s+", piece)
72
+ cur = ""
73
+ for w in words:
74
+ cand = (cur + " " + w).strip() if cur else w
75
+ if cur and expected_frames(prior, lang, len(cand))[0] > target:
76
+ final.append(cur)
77
+ cur = w
78
+ else:
79
+ cur = cand
80
+ if cur:
81
+ final.append(cur)
82
+ return final
83
+
84
+
85
+ class StopPolicy:
86
+ """The "controlled" stop rule of ServeSession.speak: argmax, tau and hazard tests, none before min_frames."""
87
+
88
+ def __init__(self, min_frames: int, tau: float = 0.35, hazard: float = 0.6) -> None:
89
+ self.min_frames = int(min_frames)
90
+ self.tau = float(tau)
91
+ self.hazard = float(hazard)
92
+ self.H = 0.0
93
+
94
+ def check(self, t: int, p: np.ndarray, aeos_class: int = 2048) -> str | None:
95
+ """
96
+ p: (vocab + 1,) probability distribution over cb0 classes.
97
+ Returns: 'aeos_argmax', 'aeos_tau', 'aeos_hazard', or None
98
+ """
99
+ if t < self.min_frames:
100
+ return None
101
+
102
+ p_aeos = float(p[aeos_class])
103
+ self.H = 1.0 - (1.0 - self.H) * (1.0 - p_aeos)
104
+
105
+ if int(np.argmax(p)) == aeos_class:
106
+ return "aeos_argmax"
107
+ if p_aeos >= self.tau:
108
+ return "aeos_tau"
109
+ if self.H >= self.hazard:
110
+ return "aeos_hazard"
111
+ return None
112
+
113
+
114
+ def sample_cb0(p: np.ndarray, noise: np.ndarray, aeos_class: int = 2048) -> int:
115
+ """
116
+ aeos zeroed and renormalised, then Gumbel-max over the rest (same distribution as upstream multinomial).
117
+ """
118
+ q = np.array(p, dtype=np.float32, copy=True)
119
+ q[aeos_class] = 0.0
120
+ s = np.sum(q)
121
+ if s > 0:
122
+ q = q / s
123
+ else:
124
+ # Uniform over classes except aeos
125
+ q = np.ones_like(q)
126
+ q[aeos_class] = 0.0
127
+ q = q / np.sum(q)
128
+
129
+ # Gumbel-max on probabilities q: argmax(log(q) + noise)
130
+ # where q == 0, log(q) = -inf
131
+ with np.errstate(divide="ignore"):
132
+ log_q = np.log(q)
133
+ perturbed = log_q + noise
134
+ perturbed[aeos_class] = -np.inf
135
+ return int(np.argmax(perturbed))
src/maya_fast/depth.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import numpy as np
5
+ import onnxruntime as ort
6
+
7
+ from .ort_opts import session_options
8
+
9
+
10
+ class Depth:
11
+ def __init__(self, path: str | Path, threads: int | None = None) -> None:
12
+ self.path = Path(path)
13
+ self.opts = session_options(threads)
14
+ self.session = ort.InferenceSession(str(self.path), self.opts, providers=["CPUExecutionProvider"])
15
+
16
+ def frame(
17
+ self,
18
+ h: np.ndarray,
19
+ cb0: int | np.integer | np.ndarray,
20
+ mem: np.ndarray,
21
+ noise: np.ndarray,
22
+ ) -> np.ndarray:
23
+ """
24
+ Inputs:
25
+ h: (d_model,) or (1, d_model) float32
26
+ cb0: int or (1,) int64
27
+ mem: (1, R, d_mem) float32
28
+ noise: (15, vocab) float32
29
+ Returns:
30
+ codes: (16,) int64
31
+ """
32
+ if h.ndim == 1:
33
+ h = h[None, :]
34
+ h = h.astype(np.float32)
35
+
36
+ if isinstance(cb0, (int, np.integer)):
37
+ cb0 = np.array([cb0], dtype=np.int64)
38
+ else:
39
+ cb0 = np.asarray(cb0, dtype=np.int64)
40
+ if cb0.ndim == 0:
41
+ cb0 = cb0.reshape(1)
42
+
43
+ mem = np.asarray(mem, dtype=np.float32)
44
+ if mem.ndim == 2:
45
+ mem = mem[None, :, :]
46
+
47
+ noise = np.asarray(noise, dtype=np.float32)
48
+ if noise.ndim == 2: # (15, n_codes) -> (1, 15, n_codes)
49
+ noise = noise[None]
50
+
51
+ feed = {
52
+ "h": h,
53
+ "cb0": cb0,
54
+ "mem": mem,
55
+ "noise": noise,
56
+ }
57
+
58
+ outputs = self.session.run(None, feed)
59
+ codes = outputs[0]
60
+ if codes.ndim > 1:
61
+ codes = codes.squeeze()
62
+ return codes.astype(np.int64)
src/maya_fast/embed.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import numpy as np
5
+
6
+
7
+ class EmbeddingTable:
8
+ """
9
+ bf16 memory map embedding lookup.
10
+ Lookup(ids) returns float32 (len(ids), dim).
11
+ Exact conversion: (memmap[ids].astype(np.uint32) << 16).view(np.float32)
12
+ """
13
+ def __init__(self, path: str | Path, rows: int, dim: int) -> None:
14
+ self.path = Path(path)
15
+ self.rows = int(rows)
16
+ self.dim = int(dim)
17
+ # raw little-endian uint16
18
+ self.mmap = np.memmap(self.path, dtype="<u2", mode="r", shape=(self.rows, self.dim))
19
+
20
+ def lookup(self, ids: np.ndarray | list[int]) -> np.ndarray:
21
+ ids_arr = np.asarray(ids, dtype=np.int64)
22
+ bf16_rows = self.mmap[ids_arr] # (..., dim) uint16
23
+ u32 = bf16_rows.astype(np.uint32) << 16
24
+ fp32 = u32.view(np.float32)
25
+ return np.ascontiguousarray(fp32)
src/maya_fast/engine.py ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Any
7
+ import numpy as np
8
+ import psutil
9
+
10
+ from .assets import PromptAssets, default_assets_dir
11
+ from .backbone import Backbone
12
+ from .depth import Depth
13
+ from .codec import MimiDecoder
14
+ from .prompt import AudioIn, build_prompt
15
+ from .sampling import gumbel
16
+ from .control import (
17
+ expected_frames,
18
+ frame_cap,
19
+ min_frames,
20
+ split_text,
21
+ StopPolicy,
22
+ sample_cb0,
23
+ )
24
+
25
+ # Speakable text check: must contain at least one alphanumeric character
26
+ ALPHANUM_PATTERN = re.compile(r"[\w\d]", re.UNICODE)
27
+
28
+
29
+ def _softmax_topk(logits: np.ndarray, temp: float = 0.7, top_k: int = 64) -> np.ndarray:
30
+ logits = np.asarray(logits, dtype=np.float32)
31
+ k = min(top_k, logits.shape[-1])
32
+ topk_idx = np.argpartition(logits, -k)[-k:]
33
+ masked = np.full_like(logits, -np.inf)
34
+ masked[topk_idx] = logits[topk_idx] / temp
35
+ max_val = np.max(masked[topk_idx])
36
+ exp_vals = np.exp(masked - max_val)
37
+ exp_vals[masked == -np.inf] = 0.0
38
+ sum_exp = np.sum(exp_vals)
39
+ if sum_exp > 0:
40
+ return exp_vals / sum_exp
41
+ probs = np.zeros_like(logits)
42
+ probs[topk_idx] = 1.0 / len(topk_idx)
43
+ return probs
44
+
45
+
46
+ class MayaFast:
47
+ def __init__(
48
+ self,
49
+ assets_dir: str | Path | None = None,
50
+ threads: int | None = None,
51
+ ) -> None:
52
+ if assets_dir is None:
53
+ self.assets_dir = default_assets_dir()
54
+ else:
55
+ self.assets_dir = Path(assets_dir)
56
+
57
+ self.threads = threads
58
+ self.assets = PromptAssets(self.assets_dir, verify=True)
59
+ self.shapes = self.assets.shapes
60
+ self.controls = self.assets.controls
61
+ self.sampling_cfg = self.controls.get("sampling", {})
62
+
63
+ # Load models
64
+ backbone_path = self.assets_dir / "backbone_wo8.onnx"
65
+ self.backbone = Backbone(
66
+ backbone_path,
67
+ threads=self.threads,
68
+ config=self.shapes,
69
+ )
70
+
71
+ depth_path = self.assets_dir / "depth_frame_fp16.onnx"
72
+ self.depth = Depth(depth_path, threads=self.threads)
73
+
74
+ mimi_path = self.assets_dir / "mimi_decoder.onnx"
75
+ self.mimi = MimiDecoder(mimi_path, threads=self.threads)
76
+
77
+ self.audio_in = self.assets.audio_in
78
+
79
+ @property
80
+ def voices(self) -> list[str]:
81
+ return sorted(list(self.assets.voices_info.keys()))
82
+
83
+ def synthesize(
84
+ self,
85
+ text: str,
86
+ voice: str = "Charvi",
87
+ seed: int = 0,
88
+ chunk_s: float | None = None,
89
+ lang: str = "mr",
90
+ ) -> tuple[np.ndarray, dict[str, Any]]:
91
+ """
92
+ Synthesize text into 24 kHz float32 audio.
93
+ Returns:
94
+ (wav, metrics)
95
+ """
96
+ # Review focus #2: Empty or punctuation-only text: expected ValueError("no speakable text")
97
+ if not text or not ALPHANUM_PATTERN.search(text):
98
+ raise ValueError("no speakable text")
99
+
100
+ if voice not in self.assets.voices_info:
101
+ raise KeyError(f"Unknown voice {voice!r}. Available: {self.voices}")
102
+
103
+ t_wall_start = time.perf_counter()
104
+ prior = self.controls.get("text_frames_prior", {})
105
+ sampling_cfg = self.sampling_cfg
106
+ q_serve = int(sampling_cfg.get("q_serve", 16))
107
+ temp = float(sampling_cfg.get("temp", 0.7))
108
+ top_k = int(sampling_cfg.get("top_k", 64))
109
+ tau = float(sampling_cfg.get("tau", 0.35))
110
+ hazard = float(sampling_cfg.get("hazard", 0.6))
111
+ aeos_class = int(self.shapes.get("n_codes", 2048))
112
+
113
+ # Split text into chunks
114
+ c_s = 12.0 if chunk_s is None else chunk_s
115
+ chunks = split_text(text, lang, prior, chunk_s=c_s)
116
+
117
+ rng = np.random.default_rng(seed)
118
+
119
+ # Speaker memory for Depth: named voices use a null memory (use_mem False)
120
+ d_mem = int(self.shapes.get("d_mem", 1024))
121
+ mem = np.zeros((1, 1, d_mem), dtype=np.float32)
122
+
123
+ gen_codes: list[np.ndarray] = []
124
+ reasons: list[str] = []
125
+ caps: list[int] = []
126
+ max_past_len = 0
127
+ max_chunk_bound = 0
128
+
129
+ backbone_time = 0.0
130
+ depth_time = 0.0
131
+ mimi_time = 0.0
132
+
133
+ for chunk_text in chunks:
134
+ # Review focus #1: Each piece a fresh KV session
135
+ self.backbone.reset()
136
+
137
+ prompt = build_prompt(chunk_text, voice, self.assets, lang=lang)
138
+
139
+ ef, er = expected_frames(prior, lang, len(chunk_text))
140
+ cap = frame_cap(chunk_text, lang, ef, er, self.controls)
141
+ min_f = min_frames(ef)
142
+ caps.append(cap)
143
+ chunk_bound = prompt.n_prompt + cap
144
+ if chunk_bound > max_chunk_bound:
145
+ max_chunk_bound = chunk_bound
146
+
147
+ # Initial backbone step on prompt
148
+ t0 = time.perf_counter()
149
+ h, cb0_logits = self.backbone.step(prompt.embeds)
150
+ backbone_time += time.perf_counter() - t0
151
+
152
+ if self.backbone.past_len > max_past_len:
153
+ max_past_len = self.backbone.past_len
154
+
155
+ stop_policy = StopPolicy(min_frames=min_f, tau=tau, hazard=hazard)
156
+ chunk_frames: list[np.ndarray] = []
157
+ reason = "cap"
158
+
159
+ for t in range(cap):
160
+ # 1. Softmax with top_k mask over cb0_logits
161
+ p = _softmax_topk(cb0_logits, temp=temp, top_k=top_k)
162
+
163
+ # 2. StopPolicy check
164
+ stop_reason = stop_policy.check(t, p, aeos_class=aeos_class)
165
+ if stop_reason is not None:
166
+ reason = stop_reason
167
+ break
168
+
169
+ # 3. Sample cb0 (aeos zeroed, Gumbel-max over the rest)
170
+ cb0_noise = gumbel(rng, (cb0_logits.shape[-1],))
171
+ cb0 = sample_cb0(p, cb0_noise, aeos_class=aeos_class)
172
+
173
+ # 4. Depth frame
174
+ depth_noise = gumbel(rng, (15, int(self.shapes.get("n_codes", 2048))))
175
+ t_d = time.perf_counter()
176
+ frame_codes = self.depth.frame(h, cb0, mem, depth_noise)
177
+ depth_time += time.perf_counter() - t_d
178
+
179
+ chunk_frames.append(frame_codes)
180
+
181
+ # 5. AudioIn feedback
182
+ fb = self.audio_in.frames(frame_codes[None, :], q_use=q_serve)
183
+
184
+ # 6. Next backbone step
185
+ t_b = time.perf_counter()
186
+ h, cb0_logits = self.backbone.step(fb)
187
+ backbone_time += time.perf_counter() - t_b
188
+
189
+ if self.backbone.past_len > max_past_len:
190
+ max_past_len = self.backbone.past_len
191
+
192
+ reasons.append(reason)
193
+ # Note on upstream aeos step:
194
+ # Upstream does one self._step(audio_in.aeos(1)) after the loop if reason.startswith("aeos").
195
+ # However, because each chunk resets the KV session and no further steps read from this KV session,
196
+ # running the final aeos step changes nothing returned and wastes compute. We skip it here.
197
+
198
+ if chunk_frames:
199
+ # chunk_frames: list of (16,)
200
+ chunk_codes_arr = np.stack(chunk_frames, axis=1) # (16, F_chunk)
201
+ gen_codes.append(chunk_codes_arr)
202
+
203
+ if gen_codes:
204
+ all_codes = np.concatenate(gen_codes, axis=1) # (16, Total_F)
205
+ total_frames = all_codes.shape[1]
206
+ t_m = time.perf_counter()
207
+ wav = self.mimi.decode(all_codes)
208
+ mimi_time += time.perf_counter() - t_m
209
+ else:
210
+ total_frames = 0
211
+ wav = np.zeros(0, dtype=np.float32)
212
+
213
+ wall_s = time.perf_counter() - t_wall_start
214
+ audio_s = len(wav) / 24000.0
215
+ rtf = wall_s / max(1e-6, audio_s)
216
+
217
+ # Peak RSS in MB
218
+ process = psutil.Process()
219
+ peak_rss_mb = process.memory_info().rss / (1024 * 1024)
220
+
221
+ metrics = {
222
+ "audio_s": audio_s,
223
+ "wall_s": wall_s,
224
+ "rtf": rtf,
225
+ "frames": total_frames,
226
+ "reasons": reasons,
227
+ "caps": caps,
228
+ "backbone_s": backbone_time,
229
+ "depth_s": depth_time,
230
+ "mimi_s": mimi_time,
231
+ "peak_rss_mb": peak_rss_mb,
232
+ "max_past_len": max_past_len,
233
+ "max_chunk_bound": max_chunk_bound,
234
+ }
235
+
236
+ return wav, metrics
src/maya_fast/ort_opts.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import onnxruntime as ort
5
+
6
+
7
+ def session_options(threads: int | None = None) -> ort.SessionOptions:
8
+ opts = ort.SessionOptions()
9
+ if threads is None:
10
+ cpu_count = os.cpu_count() or 1
11
+ threads = min(8, cpu_count)
12
+ opts.intra_op_num_threads = max(1, threads)
13
+ opts.inter_op_num_threads = 1
14
+ # Global Constraints:
15
+ # intra_op_num_threads = requested threads (default min(8, physical cores)),
16
+ # session.intra_op.allow_spinning=0, enable_cpu_mem_arena=False, ORT_ENABLE_ALL
17
+ opts.add_session_config_entry("session.intra_op.allow_spinning", "0")
18
+ opts.enable_cpu_mem_arena = False
19
+ opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
20
+ return opts
src/maya_fast/prompt.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import math
5
+
6
+ import numpy as np
7
+
8
+ from .assets import PromptAssets
9
+ from .embed import EmbeddingTable
10
+
11
+ # Port of the inference path for named voices (maya3.prompt.NamedSpeakerBuilder with V5Builder.example, the
12
+ # packer's _span and collate_v2 truncation, TTSModelV2.compose_inputs and SpeakerAnchor in eval mode).
13
+ # Checked token for token and value for value against tools/maya_export/golden_light.py by the golden tests.
14
+
15
+ RMS_EPS = float(np.finfo(np.float32).eps) # torch.nn.RMSNorm default eps for float32
16
+ _erf = np.frompyfunc(math.erf, 1, 1)
17
+
18
+
19
+ def _rms_norm(x: np.ndarray, weight: np.ndarray) -> np.ndarray:
20
+ x = np.asarray(x, dtype=np.float32)
21
+ ms = np.mean(x * x, axis=-1, keepdims=True)
22
+ return (x * (1.0 / np.sqrt(ms + RMS_EPS)) * weight).astype(np.float32)
23
+
24
+
25
+ def _gelu(x: np.ndarray) -> np.ndarray:
26
+ # exact (erf) GELU, torch's default
27
+ return (0.5 * x * (1.0 + _erf(x / math.sqrt(2.0)).astype(np.float32))).astype(np.float32)
28
+
29
+
30
+ class AudioIn:
31
+ """Audio code frames -> backbone input (port of tts/model/audio_io.py AudioInputEmbed, eval mode)."""
32
+
33
+ def __init__(self, table: EmbeddingTable, aeos_in: np.ndarray, norm_w: np.ndarray, n_q: int = 16) -> None:
34
+ self.table = table
35
+ self.aeos_in = np.asarray(aeos_in, dtype=np.float32)
36
+ self.norm_w = np.asarray(norm_w, dtype=np.float32)
37
+ self.n_q = int(n_q)
38
+ if table.rows % self.n_q:
39
+ raise ValueError("audio table rows must be a multiple of n_q")
40
+ self.n_codes = table.rows // self.n_q
41
+
42
+ def frames(self, codes: np.ndarray, q_use: int = 16) -> np.ndarray:
43
+ """
44
+ codes: (N, 16) int64 in [0, n_codes)
45
+ Returns: (N, d_model) float32
46
+ """
47
+ codes = np.asarray(codes, dtype=np.int64)
48
+ if np.any((codes[:, :q_use] < 0) | (codes[:, :q_use] >= self.n_codes)):
49
+ raise ValueError("audio code outside [0, n_codes) reached the input table")
50
+ lvl = np.arange(q_use, dtype=np.int64)[None, :]
51
+ e = self.table.lookup(codes[:, :q_use] + lvl * self.n_codes) # (N, q_use, d)
52
+ return _rms_norm(e.sum(axis=1) / math.sqrt(q_use), self.norm_w)
53
+
54
+ def aeos(self) -> np.ndarray:
55
+ """Returns: (d_model,) float32"""
56
+ return _rms_norm(self.aeos_in[None, :], self.norm_w)[0]
57
+
58
+
59
+ def speaker_anchor_null(p: dict[str, np.ndarray]) -> np.ndarray:
60
+ """SpeakerAnchor for a null speaker row: fc2(gelu(fc1(null_vec))). Returns (d_model,) float32."""
61
+ h = _gelu(p["anchor_null"] @ p["anchor_fc1_w"].T + p["anchor_fc1_b"])
62
+ return (h @ p["anchor_fc2_w"].T + p["anchor_fc2_b"]).astype(np.float32)
63
+
64
+
65
+ @dataclasses.dataclass
66
+ class Prompt:
67
+ token_ids: np.ndarray # (n_prompt,) int64, ends with the <|audio|> sentinel
68
+ embeds: np.ndarray # (n_prompt, d_model) float32
69
+ n_prompt: int
70
+
71
+
72
+ def build_prompt(text: str, voice: str, assets: PromptAssets, lang: str = "mr") -> Prompt:
73
+ """
74
+ Named voice prompt: <|lang:xx|> "{Name}: " text <|audio|>
75
+ The speaker anchor (null for named voices) is added at the <|audio|> sentinel, the last prompt position.
76
+ """
77
+ name = _resolve_voice(voice, assets)
78
+ info = assets.voices_info[name]
79
+ if info.get("mode") != "named":
80
+ raise NotImplementedError(f"voice {name!r} uses mode {info.get('mode')!r}; only named voices are supported")
81
+
82
+ rendered = "{" + name + "}: " + text.strip()
83
+ tok = assets.tokenizer
84
+ lang_id = tok.token_to_id(f"<|lang:{lang}|>")
85
+ audio_id = tok.token_to_id("<|audio|>")
86
+ if lang_id is None or audio_id is None:
87
+ raise KeyError(f"tokenizer lacks <|lang:{lang}|> or <|audio|>")
88
+
89
+ ids = [lang_id, *tok.encode(rendered, add_special_tokens=False).ids, audio_id]
90
+ token_ids = np.asarray(ids, dtype=np.int64)
91
+ embeds = assets.embed_table.lookup(token_ids)
92
+ embeds[-1] += speaker_anchor_null(assets.prompt_params)
93
+ return Prompt(token_ids=token_ids, embeds=embeds, n_prompt=len(token_ids))
94
+
95
+
96
+ def _resolve_voice(voice: str, assets: PromptAssets) -> str:
97
+ if voice in assets.voices_info:
98
+ return voice
99
+ low = str(voice).lower() # voices are case-insensitive
100
+ hits = [k for k in assets.voices_info if k.lower() == low]
101
+ if len(hits) == 1:
102
+ return hits[0]
103
+ raise KeyError(f"Unknown voice {voice!r}. Available: {sorted(assets.voices_info)}")
src/maya_fast/sampling.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+
6
+ def gumbel(rng: np.random.Generator, shape: tuple[int, ...]) -> np.ndarray:
7
+ """
8
+ Produce float32 Gumbel noise: -log(-log(U)) with U in [1e-10, 1 - 1e-7].
9
+ """
10
+ u = rng.uniform(low=1e-10, high=1.0 - 1e-7, size=shape).astype(np.float32)
11
+ return (-np.log(-np.log(u))).astype(np.float32)
12
+
13
+
14
+ def topk_gumbel_argmax(
15
+ logits: np.ndarray,
16
+ temp: float = 0.7,
17
+ top_k: int = 64,
18
+ noise: np.ndarray | None = None,
19
+ ) -> int:
20
+ """
21
+ Numpy reference of the graph's rule:
22
+ argmax(where(rank < top_k, logits / temp + noise, -inf))
23
+ """
24
+ logits = np.asarray(logits, dtype=np.float32)
25
+ if noise is None:
26
+ noise = np.zeros_like(logits)
27
+ else:
28
+ noise = np.asarray(noise, dtype=np.float32)
29
+
30
+ k = min(top_k, logits.shape[-1])
31
+ topk_idx = np.argpartition(logits, -k)[-k:]
32
+ masked_perturbed = np.full_like(logits, -np.inf)
33
+ masked_perturbed[topk_idx] = logits[topk_idx] / temp + noise[topk_idx]
34
+ return int(np.argmax(masked_perturbed))
tests/__init__.py ADDED
File without changes
tests/fixtures/__init__.py ADDED
File without changes
tests/fixtures/tiny_assets.py ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from pathlib import Path
6
+ import numpy as np
7
+ import onnx
8
+ from onnx import helper, TensorProto
9
+
10
+
11
+ def _sha256(data: bytes) -> str:
12
+ return hashlib.sha256(data).hexdigest()
13
+
14
+
15
+ def _make_tiny_backbone_onnx(out_path: Path, d: int = 16, vocab: int = 64, n_layers: int = 2, n_heads: int = 2, head_dim: int = 8) -> None:
16
+ # Inputs:
17
+ # embeds (1, T, 2048) -> here (1, T, d)
18
+ # cos (1, T, head_dim) -> (1, T, 8)
19
+ # sin (1, T, head_dim) -> (1, T, 8)
20
+ # mask (1, 1, T, P+T)
21
+ # past_k_{i}, past_v_{i} ((1, 8, P, 128)) -> ((1, n_heads, P, head_dim))
22
+ # Outputs:
23
+ # hidden (1, T, 2048) -> (1, T, d)
24
+ # cb0_logits (1, T, 2049) -> (1, T, vocab + 1)
25
+ # present_k_{i}, present_v_{i}
26
+
27
+ inputs = [
28
+ helper.make_tensor_value_info("embeds", TensorProto.FLOAT, [1, "T", d]),
29
+ helper.make_tensor_value_info("cos", TensorProto.FLOAT, [1, "T", head_dim]),
30
+ helper.make_tensor_value_info("sin", TensorProto.FLOAT, [1, "T", head_dim]),
31
+ helper.make_tensor_value_info("mask", TensorProto.FLOAT, [1, 1, "T", "total_len"]),
32
+ ]
33
+ for i in range(n_layers):
34
+ inputs.append(helper.make_tensor_value_info(f"past_k_{i}", TensorProto.FLOAT, [1, n_heads, "P", head_dim]))
35
+ inputs.append(helper.make_tensor_value_info(f"past_v_{i}", TensorProto.FLOAT, [1, n_heads, "P", head_dim]))
36
+
37
+ outputs = [
38
+ helper.make_tensor_value_info("hidden", TensorProto.FLOAT, [1, "T", d]),
39
+ helper.make_tensor_value_info("cb0_logits", TensorProto.FLOAT, [1, "T", vocab + 1]),
40
+ ]
41
+ for i in range(n_layers):
42
+ outputs.append(helper.make_tensor_value_info(f"present_k_{i}", TensorProto.FLOAT, [1, n_heads, "total_len", head_dim]))
43
+ outputs.append(helper.make_tensor_value_info(f"present_v_{i}", TensorProto.FLOAT, [1, n_heads, "total_len", head_dim]))
44
+
45
+ # For tiny mock model:
46
+ # hidden = Identity(embeds)
47
+ # cb0_logits = MatMul(hidden, W_cb0) + B_cb0
48
+ # present_k_i = Concat(past_k_i, k_new, axis=2)
49
+ # We construct a simple valid ONNX graph that outputs the right shapes.
50
+ nodes = []
51
+ initializers = []
52
+
53
+ # W_cb0: (d, vocab + 1)
54
+ np.random.seed(42)
55
+ w_cb0_val = np.random.randn(d, vocab + 1).astype(np.float32) * 0.1
56
+ # ensure aeos (index vocab) has sensible value
57
+ initializers.append(helper.make_tensor("W_cb0", TensorProto.FLOAT, [d, vocab + 1], w_cb0_val.flatten()))
58
+
59
+ nodes.append(helper.make_node("Identity", ["embeds"], ["hidden"]))
60
+ nodes.append(helper.make_node("MatMul", ["hidden", "W_cb0"], ["cb0_logits"]))
61
+
62
+ # For past_k, past_v: generate dummy k_new from embeds:
63
+ # embeds (1, T, d) -> slice or reshape to (1, n_heads, T, head_dim)
64
+ # In our case d = 16 = 2 * 8 = n_heads * head_dim!
65
+ # So we can reshape embeds -> (1, T, n_heads, head_dim) then transpose to (1, n_heads, T, head_dim)
66
+ initializers.append(helper.make_tensor("shape_k", TensorProto.INT64, [4], [1, -1, n_heads, head_dim]))
67
+ nodes.append(helper.make_node("Reshape", ["embeds", "shape_k"], ["reshaped_kv"]))
68
+ nodes.append(helper.make_node("Transpose", ["reshaped_kv"], ["k_new"], perm=[0, 2, 1, 3]))
69
+
70
+ for i in range(n_layers):
71
+ nodes.append(helper.make_node("Concat", [f"past_k_{i}", "k_new"], [f"present_k_{i}"], axis=2))
72
+ nodes.append(helper.make_node("Concat", [f"past_v_{i}", "k_new"], [f"present_v_{i}"], axis=2))
73
+
74
+ graph = helper.make_graph(nodes, "tiny_backbone", inputs, outputs, initializers)
75
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 17)], ir_version=10)
76
+ onnx.save(model, str(out_path))
77
+
78
+
79
+ def _make_tiny_depth_onnx(out_path: Path, d: int = 16, vocab: int = 64, d_mem: int = 16) -> None:
80
+ # DepthFrame (batched, as the real sampled graph):
81
+ # Inputs:
82
+ # h (B, 2048) -> (B, d)
83
+ # cb0 (B,) int64
84
+ # mem (B, R, 1024) -> (B, R, d_mem)
85
+ # noise (B, 15, 2048) float32 -> (B, 15, vocab)
86
+ # Output:
87
+ # codes (B, 16) int64
88
+
89
+ inputs = [
90
+ helper.make_tensor_value_info("h", TensorProto.FLOAT, ["B", d]),
91
+ helper.make_tensor_value_info("cb0", TensorProto.INT64, ["B"]),
92
+ helper.make_tensor_value_info("mem", TensorProto.FLOAT, ["B", "R", d_mem]),
93
+ helper.make_tensor_value_info("noise", TensorProto.FLOAT, ["B", 15, vocab]),
94
+ ]
95
+ outputs = [
96
+ helper.make_tensor_value_info("codes", TensorProto.INT64, ["B", 16]),
97
+ ]
98
+
99
+ # For tiny test: codes[:, 0] = cb0, codes[:, 1..15] = argmax(noise[:, i]) over the vocab.
100
+ initializers = [helper.make_tensor("unsq_axes", TensorProto.INT64, [1], [1])]
101
+ nodes = [
102
+ helper.make_node("ArgMax", ["noise"], ["deeper_codes"], axis=-1, keepdims=0),
103
+ helper.make_node("Unsqueeze", ["cb0", "unsq_axes"], ["cb0_col"]),
104
+ helper.make_node("Concat", ["cb0_col", "deeper_codes"], ["codes"], axis=1),
105
+ ]
106
+
107
+ graph = helper.make_graph(nodes, "tiny_depth", inputs, outputs, initializers)
108
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 17)], ir_version=10)
109
+ onnx.save(model, str(out_path))
110
+
111
+
112
+ def _make_tiny_mimi_onnx(out_path: Path) -> None:
113
+ # MimiDecoder:
114
+ # Input: codes (1, 16, F) int64
115
+ # Output: wav (1, 1, F * 1920) float32
116
+ inputs = [
117
+ helper.make_tensor_value_info("codes", TensorProto.INT64, [1, 16, "F"]),
118
+ ]
119
+ outputs = [
120
+ helper.make_tensor_value_info("wav", TensorProto.FLOAT, [1, 1, "samples"]),
121
+ ]
122
+
123
+ # In ONNX helper:
124
+ # Shape of codes -> shape (3,)
125
+ # Gather index 2 -> F
126
+ # Expand 1920 samples per frame.
127
+ # We can do: Cast(codes, FLOAT), ReduceMean along axis 1 -> (1, 1, F)
128
+ # Then Tile by [1, 1, 1920] -> (1, 1, F * 1920)
129
+ initializers = [
130
+ helper.make_tensor("tile_repeats", TensorProto.INT64, [3], [1, 1, 1920]),
131
+ ]
132
+ nodes = [
133
+ helper.make_node("Cast", ["codes"], ["codes_float"], to=TensorProto.FLOAT),
134
+ helper.make_node("ReduceMean", ["codes_float"], ["mean_frames"], axes=[1], keepdims=1),
135
+ helper.make_node("Tile", ["mean_frames", "tile_repeats"], ["wav"]),
136
+ ]
137
+
138
+ graph = helper.make_graph(nodes, "tiny_mimi", inputs, outputs, initializers)
139
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 17)], ir_version=10)
140
+ onnx.save(model, str(out_path))
141
+
142
+
143
+ def make_tiny_assets(assets_dir: Path, aeos_bias: float = 0.0) -> Path:
144
+ assets_dir = Path(assets_dir)
145
+ assets_dir.mkdir(parents=True, exist_ok=True)
146
+
147
+ d = 16
148
+ vocab = 64
149
+ n_layers = 2
150
+ n_heads = 2
151
+ head_dim = 8
152
+ d_mem = 16
153
+ n_q = 16
154
+ n_codes = vocab
155
+ ecapa_dim = 16
156
+
157
+ files_sha = {}
158
+
159
+ # 1. embed_bf16.bin
160
+ np.random.seed(123)
161
+ embeds_fp32 = np.random.randn(vocab, d).astype(np.float32)
162
+ # Convert fp32 to bf16 uint16
163
+ embeds_bf16 = (embeds_fp32.view(np.uint32) >> 16).astype(np.uint16)
164
+ embed_bytes = embeds_bf16.tobytes()
165
+ (assets_dir / "embed_bf16.bin").write_bytes(embed_bytes)
166
+ files_sha["embed_bf16.bin"] = _sha256(embed_bytes)
167
+
168
+ # 2. prompt.npz
169
+ # anchor_fc1_w, anchor_fc1_b, anchor_fc2_w, anchor_fc2_b, anchor_null, aeos, plus audio_in/<name>
170
+ # Note: audio_in parameters: table (n_q * n_codes, d), aeos_in (d,), norm.weight (d,)
171
+ prompt_dict = {
172
+ "anchor_fc1_w": np.random.randn(32, ecapa_dim).astype(np.float32),
173
+ "anchor_fc1_b": np.zeros(32, dtype=np.float32),
174
+ "anchor_fc2_w": np.zeros((d, 32), dtype=np.float32), # zero init as upstream
175
+ "anchor_fc2_b": np.zeros(d, dtype=np.float32),
176
+ "anchor_null": np.random.randn(ecapa_dim).astype(np.float32),
177
+ "aeos": np.random.randn(d).astype(np.float32),
178
+ "audio_in/aeos_in": np.random.randn(d).astype(np.float32),
179
+ "audio_in/norm": np.ones(d, dtype=np.float32),
180
+ }
181
+ np.savez(assets_dir / "prompt.npz", **prompt_dict)
182
+ files_sha["prompt.npz"] = _sha256((assets_dir / "prompt.npz").read_bytes())
183
+
184
+ # audio input table, bf16 raw uint16 row-major (same layout as the real export)
185
+ audio_f32 = np.random.randn(n_q * n_codes, d).astype(np.float32)
186
+ audio_bf16 = (audio_f32.view(np.uint32) >> 16).astype("<u2")
187
+ (assets_dir / "audio_in_bf16.bin").write_bytes(audio_bf16.tobytes())
188
+ files_sha["audio_in_bf16.bin"] = _sha256((assets_dir / "audio_in_bf16.bin").read_bytes())
189
+
190
+ # 3. voices.json + voices.npz
191
+ voices_json = {
192
+ "Charvi": {"mode": "named", "use_icl": False, "use_mem": False}
193
+ }
194
+ (assets_dir / "voices.json").write_text(json.dumps(voices_json, indent=2))
195
+ files_sha["voices.json"] = _sha256((assets_dir / "voices.json").read_bytes())
196
+
197
+
198
+ # 4. controls.json
199
+ controls_json = {
200
+ "text_frames_prior": {
201
+ "mr": {"a": 4.0, "b": 0.5, "resid_p99": 10.0}
202
+ },
203
+ "text": {"terminals": "।.!?;।॥", "fps": 12.5, "frame_s": 0.08, "chunk_s": 16.0},
204
+ "guard": {
205
+ "enabled": True,
206
+ "dur_k": 2.2,
207
+ "dur_margin_s": 1.2,
208
+ "hard_cap": 1500,
209
+ "dur_prior_coefs": {"mr": [1.0, 0.05, 0.03, 0.3, 0.3]},
210
+ "dur_prior_default": [1.0, 0.05, 0.03, 0.3, 0.3]
211
+ },
212
+ "sampling": {
213
+ "q_serve": 16,
214
+ "temp": 0.7,
215
+ "top_k": 64,
216
+ "tau": 0.35,
217
+ "hazard": 0.6
218
+ }
219
+ }
220
+ (assets_dir / "controls.json").write_text(json.dumps(controls_json, indent=2))
221
+ files_sha["controls.json"] = _sha256((assets_dir / "controls.json").read_bytes())
222
+
223
+ # 5. tokenizer.json (minimal mock tokenizer using tokenizers library)
224
+ from tokenizers import Tokenizer, models, pre_tokenizers
225
+ vocab_dict = {f"t_{i}": i for i in range(vocab - 5)}
226
+ vocab_dict["<|lang:mr|>"] = vocab - 5
227
+ vocab_dict["<|audio|>"] = vocab - 4
228
+ vocab_dict["<|aeos|>"] = vocab - 3
229
+ vocab_dict["[UNK]"] = vocab - 2
230
+ vocab_dict[" "] = vocab - 1
231
+ tok = Tokenizer(models.WordLevel(vocab_dict, unk_token="[UNK]"))
232
+ tok.pre_tokenizer = pre_tokenizers.WhitespaceSplit()
233
+ tok.save(str(assets_dir / "tokenizer.json"))
234
+ files_sha["tokenizer.json"] = _sha256((assets_dir / "tokenizer.json").read_bytes())
235
+
236
+ # 6. ONNX models
237
+ _make_tiny_backbone_onnx(assets_dir / "backbone_wo8.onnx", d=d, vocab=vocab, n_layers=n_layers, n_heads=n_heads, head_dim=head_dim)
238
+ # If aeos_bias is requested, we can adjust W_cb0 in backbone or create an alternate backbone
239
+ if aeos_bias != 0.0:
240
+ # Load and modify W_cb0 last column
241
+ m = onnx.load(str(assets_dir / "backbone_wo8.onnx"))
242
+ for init in m.graph.initializer:
243
+ if init.name == "W_cb0":
244
+ arr = onnx.numpy_helper.to_array(init)
245
+ # bias is applied to the output logits, or we add bias node.
246
+ # In tiny_backbone, cb0_logits = hidden @ W_cb0. If we add a large negative bias to index vocab (aeos):
247
+ # Let's add an explicit bias tensor:
248
+ bias_arr = np.zeros((1, 1, vocab + 1), dtype=np.float32)
249
+ bias_arr[0, 0, vocab] = aeos_bias
250
+ bias_init = helper.make_tensor("bias_cb0", TensorProto.FLOAT, [1, 1, vocab + 1], bias_arr.flatten())
251
+ m.graph.initializer.append(bias_init)
252
+ # change cb0_logits node
253
+ for node in m.graph.node:
254
+ if node.name == "" and node.op_type == "MatMul":
255
+ node.output[0] = "cb0_logits_pre"
256
+ m.graph.node.append(helper.make_node("Add", ["cb0_logits_pre", "bias_cb0"], ["cb0_logits"]))
257
+ break
258
+ onnx.save(m, str(assets_dir / "backbone_wo8.onnx"))
259
+
260
+ files_sha["backbone_wo8.onnx"] = _sha256((assets_dir / "backbone_wo8.onnx").read_bytes())
261
+
262
+ _make_tiny_depth_onnx(assets_dir / "depth_frame_fp16.onnx", d=d, vocab=vocab, d_mem=d_mem)
263
+ files_sha["depth_frame_fp16.onnx"] = _sha256((assets_dir / "depth_frame_fp16.onnx").read_bytes())
264
+
265
+ _make_tiny_mimi_onnx(assets_dir / "mimi_decoder.onnx")
266
+ files_sha["mimi_decoder.onnx"] = _sha256((assets_dir / "mimi_decoder.onnx").read_bytes())
267
+
268
+ # 7. manifest.json
269
+ manifest = {
270
+ "version": 1,
271
+ "files": files_sha,
272
+ "shapes": {
273
+ "d_model": d,
274
+ "vocab": vocab,
275
+ "n_q": n_q,
276
+ "n_codes": n_codes,
277
+ "n_layers": n_layers,
278
+ "n_heads": n_heads,
279
+ "head_dim": head_dim,
280
+ "d_mem": d_mem,
281
+ "rope_theta": 10000.0,
282
+ }
283
+ }
284
+ (assets_dir / "manifest.json").write_text(json.dumps(manifest, indent=2))
285
+
286
+ return assets_dir
tests/test_audio_utils.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ import numpy as np
3
+ import os
4
+ import sys
5
+ import tempfile
6
+ import soundfile as sf
7
+
8
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
9
+
10
+ from src.audio_utils import resample, load_and_resample, condition_audio
11
+
12
+
13
+ def tone(freq, sr, seconds=1.0, amp=0.5):
14
+ t = np.arange(int(sr * seconds)) / sr
15
+ return (amp * np.sin(2 * np.pi * freq * t)).astype(np.float32)
16
+
17
+
18
+ class TestResample(unittest.TestCase):
19
+ def test_removes_content_above_nyquist(self):
20
+ # 10 kHz is above the 8 kHz Nyquist of 16 kHz output; linear interp aliases it to 6 kHz
21
+ out = resample(tone(10000, 48000), 48000, 16000)
22
+ self.assertLess(np.sqrt(np.mean(out[1000:-1000] ** 2)), 0.01)
23
+
24
+ def test_preserves_speech_band_tone(self):
25
+ out = resample(tone(1000, 44100), 44100, 16000)
26
+ self.assertEqual(len(out), 16000)
27
+ self.assertAlmostEqual(np.sqrt(np.mean(out[1000:-1000] ** 2)), 0.5 / np.sqrt(2), delta=0.02)
28
+
29
+ def test_upsample_8k(self):
30
+ out = resample(tone(1000, 8000), 8000, 16000)
31
+ self.assertEqual(len(out), 16000)
32
+ self.assertEqual(out.dtype, np.float32)
33
+
34
+ def test_16k_passthrough(self):
35
+ x = tone(1000, 16000)
36
+ self.assertTrue(np.array_equal(resample(x, 16000, 16000), x))
37
+
38
+ def test_load_and_resample_uses_soxr(self):
39
+ stereo = np.stack([tone(10000, 48000), tone(10000, 48000)], axis=1)
40
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
41
+ path = f.name
42
+ try:
43
+ sf.write(path, stereo, 48000)
44
+ data, duration = load_and_resample(path)
45
+ finally:
46
+ os.remove(path)
47
+ self.assertEqual(data.ndim, 1)
48
+ self.assertEqual(data.dtype, np.float32)
49
+ self.assertAlmostEqual(duration, 1.0, places=3)
50
+ self.assertLess(np.sqrt(np.mean(data[1000:-1000] ** 2)), 0.01)
51
+
52
+
53
+ class TestConditionAudio(unittest.TestCase):
54
+ def test_removes_dc_normalizes_and_pads(self):
55
+ x = (0.1 * np.sin(2 * np.pi * 440 * np.arange(16000) / 16000) + 0.05).astype(np.float32)
56
+ out = condition_audio(x)
57
+ self.assertEqual(len(out), 16000 + 2 * 3200)
58
+ self.assertTrue(np.all(out[:3200] == 0) and np.all(out[-3200:] == 0))
59
+ self.assertLess(abs(out[3200:-3200].mean()), 1e-3)
60
+ self.assertAlmostEqual(np.abs(out).max(), 0.9, delta=1e-3)
61
+
62
+ def test_all_zero_stays_zero(self):
63
+ out = condition_audio(np.zeros(1600, dtype=np.float32))
64
+ self.assertFalse(np.isnan(out).any())
65
+ self.assertEqual(np.abs(out).max(), 0.0)
66
+
67
+ def test_near_silent_gain_is_capped(self):
68
+ x = (0.001 * np.sin(2 * np.pi * 440 * np.arange(16000) / 16000)).astype(np.float32)
69
+ self.assertAlmostEqual(np.abs(condition_audio(x)).max(), 0.01, delta=1e-4)
70
+
71
+ def test_empty_input_returns_padding_only(self):
72
+ self.assertEqual(len(condition_audio(np.zeros(0, dtype=np.float32))), 6400)
73
+
74
+
75
+ if __name__ == "__main__":
76
+ unittest.main()
tests/test_maya_fast_assets.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ from pathlib import Path
3
+ import tempfile
4
+ import numpy as np
5
+
6
+ from tests.fixtures.tiny_assets import make_tiny_assets
7
+ from src.maya_fast.assets import load_manifest, AssetError, PromptAssets
8
+ from src.maya_fast.embed import EmbeddingTable
9
+
10
+
11
+ class TestMayaFastAssets(unittest.TestCase):
12
+ def setUp(self):
13
+ self.temp_dir = tempfile.TemporaryDirectory()
14
+ self.assets_dir = make_tiny_assets(Path(self.temp_dir.name))
15
+
16
+ def tearDown(self):
17
+ self.temp_dir.cleanup()
18
+
19
+ def test_manifest_sha_mismatch_raises(self):
20
+ # corrupt a file
21
+ corrupt_target = self.assets_dir / "embed_bf16.bin"
22
+ with open(corrupt_target, "ab") as f:
23
+ f.write(b"\x00\x00\x00\x00")
24
+
25
+ with self.assertRaises(AssetError) as ctx:
26
+ load_manifest(self.assets_dir, verify=True)
27
+ self.assertIn("embed_bf16.bin", str(ctx.exception))
28
+
29
+ def test_manifest_missing_file_raises(self):
30
+ (self.assets_dir / "prompt.npz").unlink()
31
+ with self.assertRaises(AssetError) as ctx:
32
+ load_manifest(self.assets_dir, verify=True)
33
+ self.assertIn("prompt.npz", str(ctx.exception))
34
+
35
+ def test_bf16_lookup_exact(self):
36
+ # write 3 known float32 values that are exact in bf16, look up -> equal
37
+ with tempfile.TemporaryDirectory() as td:
38
+ p = Path(td) / "test_embed.bin"
39
+ # 1.0, -2.5, 0.125 are exactly representable in bf16
40
+ vals = np.array([[1.0, -2.5, 0.125], [0.0, 4.0, -0.5]], dtype=np.float32)
41
+ u32 = vals.view(np.uint32)
42
+ bf16 = (u32 >> 16).astype(np.uint16)
43
+ p.write_bytes(bf16.tobytes())
44
+
45
+ table = EmbeddingTable(p, rows=2, dim=3)
46
+ res = table.lookup(np.array([0, 1]))
47
+ np.testing.assert_array_equal(res, vals)
48
+
49
+
50
+ if __name__ == "__main__":
51
+ unittest.main()
tests/test_maya_fast_backbone.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ from pathlib import Path
3
+ import tempfile
4
+ import numpy as np
5
+
6
+ from tests.fixtures.tiny_assets import make_tiny_assets
7
+ from src.maya_fast.backbone import Backbone
8
+
9
+
10
+ class TestMayaFastBackbone(unittest.TestCase):
11
+ def setUp(self):
12
+ self.temp_dir = tempfile.TemporaryDirectory()
13
+ self.assets_dir = make_tiny_assets(Path(self.temp_dir.name))
14
+
15
+ def tearDown(self):
16
+ self.temp_dir.cleanup()
17
+
18
+ def test_backbone_step_kv_grows(self):
19
+ # runtime: tiny synthetic 2-layer graph built with onnx.helper in tests/fixtures/tiny_assets.py;
20
+ # past_len goes 5 -> 6 -> 7 and reset() returns it to 0
21
+ backbone_path = self.assets_dir / "backbone_wo8.onnx"
22
+ bb = Backbone(backbone_path, threads=1, manifest_path=self.assets_dir / "manifest.json")
23
+ self.assertEqual(bb.past_len, 0)
24
+
25
+ # Prefill 5 tokens
26
+ # d_model = 16 for tiny
27
+ d = 16
28
+ prompt_embeds = np.random.randn(5, d).astype(np.float32)
29
+ h, cb0 = bb.step(prompt_embeds)
30
+ self.assertEqual(bb.past_len, 5)
31
+ self.assertEqual(h.shape, (d,))
32
+ self.assertEqual(cb0.shape, (65,)) # vocab + 1
33
+
34
+ # Step 6
35
+ tok1 = np.random.randn(1, d).astype(np.float32)
36
+ h, cb0 = bb.step(tok1)
37
+ self.assertEqual(bb.past_len, 6)
38
+
39
+ # Step 7
40
+ tok2 = np.random.randn(1, d).astype(np.float32)
41
+ h, cb0 = bb.step(tok2)
42
+ self.assertEqual(bb.past_len, 7)
43
+
44
+ # Reset
45
+ bb.reset()
46
+ self.assertEqual(bb.past_len, 0)
47
+
48
+
49
+ if __name__ == "__main__":
50
+ unittest.main()
tests/test_maya_fast_codec.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ from pathlib import Path
3
+ import tempfile
4
+ import numpy as np
5
+
6
+ from tests.fixtures.tiny_assets import make_tiny_assets
7
+ from src.maya_fast.assets import default_assets_dir
8
+ from src.maya_fast.codec import MimiDecoder
9
+
10
+
11
+ class TestMayaFastCodec(unittest.TestCase):
12
+ def setUp(self):
13
+ self.temp_dir = tempfile.TemporaryDirectory()
14
+ self.assets_dir = make_tiny_assets(Path(self.temp_dir.name))
15
+
16
+ def tearDown(self):
17
+ self.temp_dir.cleanup()
18
+
19
+ def test_codec_length(self):
20
+ # tiny assets: F = 5 -> 9600 samples
21
+ codec_path = self.assets_dir / "mimi_decoder.onnx"
22
+ mimi = MimiDecoder(codec_path, threads=1)
23
+
24
+ F = 5
25
+ codes = np.random.randint(0, 64, size=(16, F), dtype=np.int64)
26
+ wav = mimi.decode(codes)
27
+
28
+ self.assertEqual(wav.shape, (F * 1920,))
29
+ self.assertEqual(wav.dtype, np.float32)
30
+
31
+
32
+ class TestMayaFastCodecReal(unittest.TestCase):
33
+ def test_real_decoder_length(self):
34
+ path = default_assets_dir() / "mimi_decoder.onnx"
35
+ if not path.exists():
36
+ self.skipTest(f"real Mimi decoder not found at {path}")
37
+ mimi = MimiDecoder(path, threads=1)
38
+ codes = np.random.default_rng(0).integers(0, 2048, size=(16, 5), dtype=np.int64)
39
+ wav = mimi.decode(codes)
40
+ self.assertEqual(wav.shape, (5 * 1920,))
41
+ self.assertTrue(np.all(np.isfinite(wav)))
42
+
43
+
44
+ if __name__ == "__main__":
45
+ unittest.main()
tests/test_maya_fast_control.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import unittest
3
+ from pathlib import Path
4
+ import numpy as np
5
+
6
+ from src.maya_fast.assets import PromptAssets, default_assets_dir
7
+ from src.maya_fast.control import (
8
+ expected_frames,
9
+ frame_cap,
10
+ min_frames,
11
+ split_text,
12
+ StopPolicy,
13
+ sample_cb0,
14
+ )
15
+
16
+
17
+ class TestMayaFastControl(unittest.TestCase):
18
+ def test_controls_basic(self):
19
+ prior = {"mr": {"a": 4.0, "b": 0.5, "resid_p99": 10.0}}
20
+ ef, er = expected_frames(prior, "mr", 20)
21
+ self.assertEqual(ef, 14.0)
22
+ self.assertEqual(er, 10.0)
23
+
24
+ controls = {
25
+ "guard": {
26
+ "enabled": True,
27
+ "dur_k": 2.2,
28
+ "dur_margin_s": 1.2,
29
+ "hard_cap": 1500,
30
+ "dur_prior_coefs": {"mr": [1.0, 0.05, 0.03, 0.3, 0.3]},
31
+ "dur_prior_default": [1.0, 0.05, 0.03, 0.3, 0.3],
32
+ }
33
+ }
34
+ cap = frame_cap("नमस्कार", "mr", ef, er, controls)
35
+ self.assertGreater(cap, 0)
36
+ self.assertLessEqual(cap, 1500)
37
+
38
+ mf = min_frames(ef)
39
+ self.assertGreaterEqual(mf, 4)
40
+
41
+ # test split_text
42
+ text = "हा पहिला भाग आहे. हा दुसरा भाग आहे."
43
+ chunks = split_text(text, "mr", prior, chunk_s=1.0)
44
+ self.assertGreater(len(chunks), 1)
45
+
46
+ def test_stop_policy_reasons(self):
47
+ # test_stop_policy_reasons: hand-built p vectors trigger each reason at the right t; nothing fires before min_frames
48
+ policy = StopPolicy(min_frames=5, tau=0.35, hazard=0.6)
49
+ aeos_class = 2048
50
+ p = np.zeros(2049, dtype=np.float32)
51
+
52
+ # Before min_frames, none should fire even if aeos is 1.0
53
+ p[aeos_class] = 1.0
54
+ for t in range(5):
55
+ self.assertIsNone(policy.check(t, p))
56
+
57
+ # At t = 5, argmax aeos triggers aeos_argmax
58
+ self.assertEqual(policy.check(5, p), "aeos_argmax")
59
+
60
+ # Test tau trigger when argmax is another token
61
+ policy = StopPolicy(min_frames=5, tau=0.35, hazard=0.6)
62
+ p = np.zeros(2049, dtype=np.float32)
63
+ p[0] = 0.5
64
+ p[aeos_class] = 0.4 # >= 0.35 but argmax is 0
65
+ self.assertEqual(policy.check(5, p), "aeos_tau")
66
+
67
+ # Test hazard trigger: accumulated hazard 1 - prod(1 - p_aeos) >= 0.6
68
+ # If p_aeos = 0.2 at each step after min_frames:
69
+ # t=5: 1 - 0.8 = 0.2
70
+ # t=6: 1 - 0.8^2 = 0.36
71
+ # t=7: 1 - 0.8^3 = 0.488
72
+ # t=8: 1 - 0.8^4 = 0.5904
73
+ # t=9: 1 - 0.8^5 = 0.67232 >= 0.6 -> aeos_hazard!
74
+ policy = StopPolicy(min_frames=5, tau=0.35, hazard=0.6)
75
+ p = np.zeros(2049, dtype=np.float32)
76
+ p[0] = 0.7
77
+ p[aeos_class] = 0.2
78
+ for t in range(5, 9):
79
+ self.assertIsNone(policy.check(t, p))
80
+ self.assertEqual(policy.check(9, p), "aeos_hazard")
81
+
82
+ def test_sample_cb0(self):
83
+ # aeos zeroed, Gumbel-max over the rest
84
+ aeos_class = 64
85
+ p = np.zeros(65, dtype=np.float32)
86
+ p[aeos_class] = 0.99
87
+ p[10] = 0.01
88
+
89
+ noise = np.zeros(65, dtype=np.float32)
90
+ # Even though aeos had 0.99, sample_cb0 masks aeos out, so token 10 must be chosen
91
+ chosen = sample_cb0(p, noise, aeos_class=aeos_class)
92
+ self.assertEqual(chosen, 10)
93
+
94
+
95
+ GOLDEN = Path.home() / ".cache" / "maya_fast" / "golden"
96
+
97
+
98
+ class TestMayaFastControlGolden(unittest.TestCase):
99
+ """Sentence controls (ef, er, cap, minimum, split_text) must equal the upstream values in light.json."""
100
+
101
+ @classmethod
102
+ def setUpClass(cls):
103
+ assets_dir = default_assets_dir()
104
+ if not (GOLDEN / "light.json").exists() or not (assets_dir / "manifest.json").exists():
105
+ raise unittest.SkipTest(f"goldens at {GOLDEN} or assets at {assets_dir} not found")
106
+ with open(GOLDEN / "light.json", "r", encoding="utf-8") as f:
107
+ cls.gj = json.load(f)
108
+ cls.controls = PromptAssets(assets_dir).controls
109
+
110
+ def test_controls_match_golden(self):
111
+ prior = self.controls["text_frames_prior"]
112
+ for s_id, item in self.gj["sentences"].items():
113
+ text = item["text"]
114
+ ef, er = expected_frames(prior, "mr", len(text))
115
+ self.assertAlmostEqual(ef, item["ef"], places=9, msg=s_id)
116
+ self.assertAlmostEqual(er, item["er"], places=9, msg=s_id)
117
+ self.assertEqual(frame_cap(text, "mr", ef, er, self.controls), item["cap"], s_id)
118
+ self.assertEqual(min_frames(ef), item["minimum"], s_id)
119
+ self.assertEqual(split_text(text, "mr", prior, chunk_s=16.0), item["split_text"], s_id)
120
+
121
+
122
+ if __name__ == "__main__":
123
+ unittest.main()
tests/test_maya_fast_depth.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ from pathlib import Path
3
+ import tempfile
4
+ import numpy as np
5
+
6
+ from tests.fixtures.tiny_assets import make_tiny_assets
7
+ from src.maya_fast.depth import Depth
8
+
9
+
10
+ class TestMayaFastDepth(unittest.TestCase):
11
+ def setUp(self):
12
+ self.temp_dir = tempfile.TemporaryDirectory()
13
+ self.assets_dir = make_tiny_assets(Path(self.temp_dir.name))
14
+
15
+ def tearDown(self):
16
+ self.temp_dir.cleanup()
17
+
18
+ def test_depth_runtime_shapes(self):
19
+ # tiny assets: frame() returns 16 codes in [0, 2048), codes[0] == cb0
20
+ depth_path = self.assets_dir / "depth_frame_fp16.onnx"
21
+ depth = Depth(depth_path, threads=1)
22
+
23
+ d = 16
24
+ vocab = 64
25
+ d_mem = 16
26
+
27
+ h = np.random.randn(d).astype(np.float32)
28
+ cb0 = 17
29
+ mem = np.random.randn(1, 1, d_mem).astype(np.float32)
30
+ noise = np.random.randn(15, vocab).astype(np.float32)
31
+
32
+ codes = depth.frame(h, cb0, mem, noise)
33
+
34
+ self.assertEqual(codes.shape, (16,))
35
+ self.assertEqual(codes.dtype, np.int64)
36
+ self.assertEqual(codes[0], cb0)
37
+ self.assertTrue(np.all((codes >= 0) & (codes < 2048)))
38
+
39
+
40
+ if __name__ == "__main__":
41
+ unittest.main()
tests/test_maya_fast_engine.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import Path
5
+ import tempfile
6
+ import unittest
7
+ import numpy as np
8
+
9
+ from tests.fixtures.tiny_assets import make_tiny_assets
10
+ from src.maya_fast.engine import MayaFast
11
+ from src.maya_fast.control import split_text
12
+
13
+
14
+ class TestMayaFastEngine(unittest.TestCase):
15
+ def setUp(self):
16
+ self.temp_dir = tempfile.TemporaryDirectory()
17
+ self.assets_dir = make_tiny_assets(Path(self.temp_dir.name))
18
+
19
+ def tearDown(self):
20
+ self.temp_dir.cleanup()
21
+
22
+ def test_synthesize_returns_audio(self):
23
+ # test_synthesize_returns_audio (length = frames * 1920)
24
+ engine = MayaFast(assets_dir=self.assets_dir, threads=1)
25
+ wav, metrics = engine.synthesize("t_1 t_2", voice="Charvi", seed=42)
26
+ self.assertEqual(len(wav), metrics["frames"] * 1920)
27
+ self.assertEqual(wav.dtype, np.float32)
28
+ self.assertIn("audio_s", metrics)
29
+ self.assertIn("wall_s", metrics)
30
+ self.assertIn("rtf", metrics)
31
+ self.assertIn("reasons", metrics)
32
+
33
+ def test_same_seed_same_audio(self):
34
+ engine = MayaFast(assets_dir=self.assets_dir, threads=1)
35
+ wav1, _ = engine.synthesize("t_1 t_2", voice="Charvi", seed=123)
36
+ wav2, _ = engine.synthesize("t_1 t_2", voice="Charvi", seed=123)
37
+ np.testing.assert_array_equal(wav1, wav2)
38
+
39
+ def test_empty_text_raises(self):
40
+ engine = MayaFast(assets_dir=self.assets_dir, threads=1)
41
+ with self.assertRaises(ValueError):
42
+ engine.synthesize("", voice="Charvi")
43
+ with self.assertRaises(ValueError):
44
+ engine.synthesize("।,.", voice="Charvi")
45
+
46
+ def test_cap_stops_generation(self):
47
+ # tiny assets built with an aeos logit bias of -100 -> reasons == ["cap"], frames == cap
48
+ with tempfile.TemporaryDirectory() as td:
49
+ biased_assets_dir = make_tiny_assets(Path(td), aeos_bias=-100.0)
50
+ engine = MayaFast(assets_dir=biased_assets_dir, threads=1)
51
+ wav, metrics = engine.synthesize("t_1", voice="Charvi", seed=0)
52
+ self.assertEqual(metrics["reasons"], ["cap"])
53
+
54
+ def test_long_text_is_split_and_kv_bounded(self):
55
+ # 3-sentence text -> len(reasons) == len(split_text(...)), and Backbone.past_len never exceeds the largest piece's prompt + cap
56
+ text = "t_1 t_2. t_1 t_3. t_2 t_3."
57
+ engine = MayaFast(assets_dir=self.assets_dir, threads=1)
58
+ prior = engine.controls.get("text_frames_prior", {})
59
+ expected_chunks = split_text(text, "mr", prior, chunk_s=0.5)
60
+
61
+ wav, metrics = engine.synthesize(text, voice="Charvi", seed=0, chunk_s=0.5)
62
+ self.assertEqual(len(metrics["reasons"]), len(expected_chunks))
63
+ self.assertLessEqual(metrics["max_past_len"], metrics["max_chunk_bound"])
64
+
65
+ def test_thread_count_does_not_change_output(self):
66
+ # threads 1 and 16 -> identical wav
67
+ engine1 = MayaFast(assets_dir=self.assets_dir, threads=1)
68
+ engine2 = MayaFast(assets_dir=self.assets_dir, threads=16)
69
+ wav1, _ = engine1.synthesize("t_1 t_2", voice="Charvi", seed=7)
70
+ wav2, _ = engine2.synthesize("t_1 t_2", voice="Charvi", seed=7)
71
+ np.testing.assert_array_equal(wav1, wav2)
72
+
73
+ def test_unknown_voice_lists_voices(self):
74
+ engine = MayaFast(assets_dir=self.assets_dir, threads=1)
75
+ with self.assertRaises(KeyError) as ctx:
76
+ engine.synthesize("t_1", voice="UnknownVoice")
77
+ self.assertIn("Charvi", str(ctx.exception))
78
+
79
+ def test_runtime_has_no_torch(self):
80
+ # ast scan of every file under src/maya_fast/: no torch import
81
+ pkg_dir = Path(__file__).resolve().parent.parent / "src" / "maya_fast"
82
+ for py_file in pkg_dir.glob("**/*.py"):
83
+ with open(py_file, "r", encoding="utf-8") as f:
84
+ tree = ast.parse(f.read(), filename=str(py_file))
85
+ for node in ast.walk(tree):
86
+ if isinstance(node, ast.Import):
87
+ for alias in node.names:
88
+ self.assertNotEqual(alias.name.split(".")[0], "torch", f"torch imported in {py_file}")
89
+ elif isinstance(node, ast.ImportFrom):
90
+ if node.module:
91
+ self.assertNotEqual(node.module.split(".")[0], "torch", f"torch imported in {py_file}")
92
+
93
+
94
+ if __name__ == "__main__":
95
+ unittest.main()