gamemlx 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gamemlx-0.1.0/.gitignore +9 -0
- gamemlx-0.1.0/PKG-INFO +84 -0
- gamemlx-0.1.0/README.md +67 -0
- gamemlx-0.1.0/docs/ALIGNMENT.md +89 -0
- gamemlx-0.1.0/docs/BENCH.md +20 -0
- gamemlx-0.1.0/gamemlx/__init__.py +73 -0
- gamemlx-0.1.0/gamemlx/cli.py +199 -0
- gamemlx-0.1.0/gamemlx/config.py +113 -0
- gamemlx-0.1.0/gamemlx/d3pm.py +43 -0
- gamemlx-0.1.0/gamemlx/decoding.py +60 -0
- gamemlx-0.1.0/gamemlx/download.py +103 -0
- gamemlx-0.1.0/gamemlx/features.py +62 -0
- gamemlx-0.1.0/gamemlx/functional.py +48 -0
- gamemlx-0.1.0/gamemlx/model.py +231 -0
- gamemlx-0.1.0/gamemlx/modules.py +769 -0
- gamemlx-0.1.0/gamemlx/slicer.py +116 -0
- gamemlx-0.1.0/gamemlx/utils.py +27 -0
- gamemlx-0.1.0/pyproject.toml +28 -0
- gamemlx-0.1.0/scripts/bench.py +179 -0
- gamemlx-0.1.0/scripts/compare.py +163 -0
- gamemlx-0.1.0/scripts/convert_to_mlx.py +162 -0
- gamemlx-0.1.0/scripts/run_mlx_aligned.py +88 -0
- gamemlx-0.1.0/scripts/run_torch_aligned.py +112 -0
- gamemlx-0.1.0/scripts/test_files.json +24 -0
gamemlx-0.1.0/.gitignore
ADDED
gamemlx-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: gamemlx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pure MLX inference package for GAME (vocal MIDI extraction) on Apple Silicon
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: click
|
|
8
|
+
Requires-Dist: huggingface-hub
|
|
9
|
+
Requires-Dist: librosa
|
|
10
|
+
Requires-Dist: mido
|
|
11
|
+
Requires-Dist: mlx>=0.26
|
|
12
|
+
Requires-Dist: numpy<2
|
|
13
|
+
Requires-Dist: pyyaml
|
|
14
|
+
Requires-Dist: safetensors
|
|
15
|
+
Requires-Dist: soundfile
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# gamemlx
|
|
19
|
+
|
|
20
|
+
Pure [MLX](https://github.com/ml-explore/mlx) inference for
|
|
21
|
+
[GAME](https://github.com/openvpi/GAME) (Guided Automatic Music Extraction) —
|
|
22
|
+
vocal → MIDI, running on Apple Silicon GPU. Numerically aligned with the
|
|
23
|
+
torch reference; **fp32 output is bit-comparable on the test set (20/20 files,
|
|
24
|
+
all onset/offset/pitch diffs 0.0000) and ~9.4× faster than torch CPU.**
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uv tool install gamemlx # or: pip install gamemlx
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick start
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# download weights (HF: baicai1145/GAME-MLX-medium, ModelScope fallback) and extract
|
|
36
|
+
gamemlx extract vocals.wav -m medium --language zh
|
|
37
|
+
|
|
38
|
+
# local model dir, fp16, custom decoding params
|
|
39
|
+
gamemlx extract song/ -m ./weights/GAME-1.0-medium-mlx \
|
|
40
|
+
--precision fp16 --language zh --tempo 120 \
|
|
41
|
+
--seg-threshold 0.2 --seg-radius 0.02 --est-threshold 0.2 \
|
|
42
|
+
--t0 0 --nsteps 8 --output-dir out/
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Python API:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from gamemlx import load_model, extract
|
|
49
|
+
|
|
50
|
+
model, lang_map = load_model("medium", precision="fp32")
|
|
51
|
+
mids = extract(model, "vocals.wav", language="zh", rng_seed=42)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Models
|
|
55
|
+
|
|
56
|
+
| alias | HuggingFace | files |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `small` / `medium` / `large` | `baicai1145/GAME-MLX-{small,medium,large}` | config.yaml, lang_map.json, weights(.fp16).safetensors |
|
|
59
|
+
|
|
60
|
+
Weights convert 1:1 from official GAME checkpoints (torch state_dict keys kept
|
|
61
|
+
verbatim). First use downloads to `~/.cache/gamemlx`; ModelScope mirrors are
|
|
62
|
+
used automatically when HF is unreachable.
|
|
63
|
+
|
|
64
|
+
## Alignment & performance (vs GAME torch CPU, 20-file set)
|
|
65
|
+
|
|
66
|
+
| precision | sequence match | max pitch diff | max onset/offset diff |
|
|
67
|
+
|---|---|---|---|
|
|
68
|
+
| fp32 | 20/20 identical | 0.0000 | 0.0000 s |
|
|
69
|
+
| fp16 | 19/20 (one borderline d3pm flip, analyzed) | 0.0011 | 0.0000 s |
|
|
70
|
+
|
|
71
|
+
| engine | total inference (20 files) | avg / file | peak RSS |
|
|
72
|
+
|---|---|---|---|
|
|
73
|
+
| torch CPU (32-true) | 32.19 s | 1.609 s | 971 MB |
|
|
74
|
+
| **gamemlx fp32** | **3.44 s** | **0.172 s (9.4×)** | 616 MB |
|
|
75
|
+
| gamemlx fp16 | 4.76 s | 0.238 s | 441 MB |
|
|
76
|
+
|
|
77
|
+
Details: [docs/ALIGNMENT.md](docs/ALIGNMENT.md), [docs/BENCH.md](docs/BENCH.md).
|
|
78
|
+
|
|
79
|
+
## Notes
|
|
80
|
+
|
|
81
|
+
- d3pm sampling randomness is injectable (`rng_matrices` / `--rng-seed`) for
|
|
82
|
+
reproducible comparisons against torch.
|
|
83
|
+
- fp32 recommended for exact parity; fp16 saves memory (~111MB wired) with
|
|
84
|
+
sub-0.002 pitch deviation.
|
gamemlx-0.1.0/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# gamemlx
|
|
2
|
+
|
|
3
|
+
Pure [MLX](https://github.com/ml-explore/mlx) inference for
|
|
4
|
+
[GAME](https://github.com/openvpi/GAME) (Guided Automatic Music Extraction) —
|
|
5
|
+
vocal → MIDI, running on Apple Silicon GPU. Numerically aligned with the
|
|
6
|
+
torch reference; **fp32 output is bit-comparable on the test set (20/20 files,
|
|
7
|
+
all onset/offset/pitch diffs 0.0000) and ~9.4× faster than torch CPU.**
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv tool install gamemlx # or: pip install gamemlx
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# download weights (HF: baicai1145/GAME-MLX-medium, ModelScope fallback) and extract
|
|
19
|
+
gamemlx extract vocals.wav -m medium --language zh
|
|
20
|
+
|
|
21
|
+
# local model dir, fp16, custom decoding params
|
|
22
|
+
gamemlx extract song/ -m ./weights/GAME-1.0-medium-mlx \
|
|
23
|
+
--precision fp16 --language zh --tempo 120 \
|
|
24
|
+
--seg-threshold 0.2 --seg-radius 0.02 --est-threshold 0.2 \
|
|
25
|
+
--t0 0 --nsteps 8 --output-dir out/
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Python API:
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from gamemlx import load_model, extract
|
|
32
|
+
|
|
33
|
+
model, lang_map = load_model("medium", precision="fp32")
|
|
34
|
+
mids = extract(model, "vocals.wav", language="zh", rng_seed=42)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Models
|
|
38
|
+
|
|
39
|
+
| alias | HuggingFace | files |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| `small` / `medium` / `large` | `baicai1145/GAME-MLX-{small,medium,large}` | config.yaml, lang_map.json, weights(.fp16).safetensors |
|
|
42
|
+
|
|
43
|
+
Weights convert 1:1 from official GAME checkpoints (torch state_dict keys kept
|
|
44
|
+
verbatim). First use downloads to `~/.cache/gamemlx`; ModelScope mirrors are
|
|
45
|
+
used automatically when HF is unreachable.
|
|
46
|
+
|
|
47
|
+
## Alignment & performance (vs GAME torch CPU, 20-file set)
|
|
48
|
+
|
|
49
|
+
| precision | sequence match | max pitch diff | max onset/offset diff |
|
|
50
|
+
|---|---|---|---|
|
|
51
|
+
| fp32 | 20/20 identical | 0.0000 | 0.0000 s |
|
|
52
|
+
| fp16 | 19/20 (one borderline d3pm flip, analyzed) | 0.0011 | 0.0000 s |
|
|
53
|
+
|
|
54
|
+
| engine | total inference (20 files) | avg / file | peak RSS |
|
|
55
|
+
|---|---|---|---|
|
|
56
|
+
| torch CPU (32-true) | 32.19 s | 1.609 s | 971 MB |
|
|
57
|
+
| **gamemlx fp32** | **3.44 s** | **0.172 s (9.4×)** | 616 MB |
|
|
58
|
+
| gamemlx fp16 | 4.76 s | 0.238 s | 441 MB |
|
|
59
|
+
|
|
60
|
+
Details: [docs/ALIGNMENT.md](docs/ALIGNMENT.md), [docs/BENCH.md](docs/BENCH.md).
|
|
61
|
+
|
|
62
|
+
## Notes
|
|
63
|
+
|
|
64
|
+
- d3pm sampling randomness is injectable (`rng_matrices` / `--rng-seed`) for
|
|
65
|
+
reproducible comparisons against torch.
|
|
66
|
+
- fp32 recommended for exact parity; fp16 saves memory (~111MB wired) with
|
|
67
|
+
sub-0.002 pitch deviation.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# gamemlx vs GAME torch alignment
|
|
2
|
+
|
|
3
|
+
## Summary
|
|
4
|
+
|
|
5
|
+
| precision | files pass | criterion | result |
|
|
6
|
+
|---|---|---|---|
|
|
7
|
+
| fp32 | 20/20 | pitch<0.01, onset/offset<0.01s, sequence identical | **all diffs 0.0000** |
|
|
8
|
+
| fp16 | 19/20 | pitch<0.05, sequence match >=95% | 95% sequence match, max pitch diff 0.0011 except one borderline flip (analyzed below) |
|
|
9
|
+
|
|
10
|
+
Randomness control: d3pm draws injected from a shared numpy PCG64 generator
|
|
11
|
+
(seed 42, restarted per chunk) on BOTH sides — torch via monkeypatched
|
|
12
|
+
`modules.d3pm.remove_boundaries`, gamemlx via `rng_matrices=` argument.
|
|
13
|
+
|
|
14
|
+
- 20-file set: `/Volumes/1T/repos/midi/game-mlx/scripts/test_files.json`
|
|
15
|
+
- d3pm randomness: injected shared numpy generator, seed 42, restarted per chunk on both sides
|
|
16
|
+
- torch: GAME @ .venv2, CPU, precision 32-true, batch 1
|
|
17
|
+
- gamemlx: MLX 0.32 (Apple Silicon GPU), fp32 & fp16
|
|
18
|
+
|
|
19
|
+
## fp32 (must: pitch<0.01, onset/offset<0.01s, sequence identical)
|
|
20
|
+
|
|
21
|
+
| file | ref notes | mlx notes | max onset d (s) | max offset d (s) | max pitch d | pass |
|
|
22
|
+
|---|---|---|---|---|---|---|
|
|
23
|
+
| 就算是客人没吃过这种菜,只要闻闻菜谱,就知道大概是什么味 | 29 | 29 | 0.0000 | 0.0000 | 0.0000 | ✅ |
|
|
24
|
+
| 真不错,那我就在旅途中期待你们的厨艺了。 | 19 | 19 | 0.0000 | 0.0000 | 0.0000 | ✅ |
|
|
25
|
+
| 对、对!姐姐是怎么让枪转来转去的,教教可莉吧? | 20 | 20 | 0.0000 | 0.0000 | 0.0000 | ✅ |
|
|
26
|
+
| vo_NTAQ001_1_together_01 | 8 | 8 | 0.0000 | 0.0000 | 0.0000 | ✅ |
|
|
27
|
+
| vo_DQEQ002_1_together_01 | 4 | 4 | 0.0000 | 0.0000 | 0.0000 | ✅ |
|
|
28
|
+
| vo_NTAQ001_1_together_01 | 4 | 4 | 0.0000 | 0.0000 | 0.0000 | ✅ |
|
|
29
|
+
| vo_FDAQ001_5_furina_09 | 1 | 1 | 0.0000 | 0.0000 | 0.0000 | ✅ |
|
|
30
|
+
| vo_FDAQ002_11_furina_01 | 1 | 1 | 0.0000 | 0.0000 | 0.0000 | ✅ |
|
|
31
|
+
| vo_FDAQ108_12_furina_02 | 0 | 0 | 0.0000 | 0.0000 | 0.0000 | ✅ |
|
|
32
|
+
| vo_KLLQ003_3_klee_01 | 9 | 9 | 0.0000 | 0.0000 | 0.0000 | ✅ |
|
|
33
|
+
| vo_RCJWQ001_17_klee_05 | 15 | 15 | 0.0000 | 0.0000 | 0.0000 | ✅ |
|
|
34
|
+
| vo_MNLQ003_2_klee_03 | 17 | 17 | 0.0000 | 0.0000 | 0.0000 | ✅ |
|
|
35
|
+
| vo_JNJEQ003_6_klee_02b | 39 | 39 | 0.0000 | 0.0000 | 0.0000 | ✅ |
|
|
36
|
+
| vo_EQHDJ403_16_hutao_05 | 19 | 19 | 0.0000 | 0.0000 | 0.0000 | ✅ |
|
|
37
|
+
| vo_YQEQ203_8_klee_11a | 14 | 14 | 0.0000 | 0.0000 | 0.0000 | ✅ |
|
|
38
|
+
| vo_ayaka_life_less30_teammat | 6 | 6 | 0.0000 | 0.0000 | 0.0000 | ✅ |
|
|
39
|
+
| vo_furina_battle_skill1_02 | 4 | 4 | 0.0000 | 0.0000 | 0.0000 | ✅ |
|
|
40
|
+
| vo_nahida_battle_skill3_03 | 5 | 5 | 0.0000 | 0.0000 | 0.0000 | ✅ |
|
|
41
|
+
| vo_AYLQ003_5_ayaka_28 | 27 | 27 | 0.0000 | 0.0000 | 0.0000 | ✅ |
|
|
42
|
+
| vo_SCHJEQ001_8_ayaka_06 | 6 | 6 | 0.0000 | 0.0000 | 0.0000 | ✅ |
|
|
43
|
+
|
|
44
|
+
**fp32: 20/20 files pass**
|
|
45
|
+
|
|
46
|
+
## fp16 (must: pitch<0.05, sequence match >=95%)
|
|
47
|
+
|
|
48
|
+
| file | ref notes | mlx notes | max onset d (s) | max offset d (s) | max pitch d | pass |
|
|
49
|
+
|---|---|---|---|---|---|---|
|
|
50
|
+
| 就算是客人没吃过这种菜,只要闻闻菜谱,就知道大概是什么味 | 29 | 29 | 0.0000 | 0.0000 | 0.0011 | ✅ |
|
|
51
|
+
| 真不错,那我就在旅途中期待你们的厨艺了。 | 19 | 20 | 0.8700 | 0.8900 | 10.0491 | ❌ |
|
|
52
|
+
| 对、对!姐姐是怎么让枪转来转去的,教教可莉吧? | 20 | 20 | 0.0000 | 0.0000 | 0.0004 | ✅ |
|
|
53
|
+
| vo_NTAQ001_1_together_01 | 8 | 8 | 0.0000 | 0.0000 | 0.0003 | ✅ |
|
|
54
|
+
| vo_DQEQ002_1_together_01 | 4 | 4 | 0.0000 | 0.0000 | 0.0003 | ✅ |
|
|
55
|
+
| vo_NTAQ001_1_together_01 | 4 | 4 | 0.0000 | 0.0000 | 0.0002 | ✅ |
|
|
56
|
+
| vo_FDAQ001_5_furina_09 | 1 | 1 | 0.0000 | 0.0000 | 0.0000 | ✅ |
|
|
57
|
+
| vo_FDAQ002_11_furina_01 | 1 | 1 | 0.0000 | 0.0000 | 0.0006 | ✅ |
|
|
58
|
+
| vo_FDAQ108_12_furina_02 | 0 | 0 | 0.0000 | 0.0000 | 0.0000 | ✅ |
|
|
59
|
+
| vo_KLLQ003_3_klee_01 | 9 | 9 | 0.0000 | 0.0000 | 0.0002 | ✅ |
|
|
60
|
+
| vo_RCJWQ001_17_klee_05 | 15 | 15 | 0.0000 | 0.0000 | 0.0002 | ✅ |
|
|
61
|
+
| vo_MNLQ003_2_klee_03 | 17 | 17 | 0.0000 | 0.0000 | 0.0009 | ✅ |
|
|
62
|
+
| vo_JNJEQ003_6_klee_02b | 39 | 39 | 0.0000 | 0.0000 | 0.0004 | ✅ |
|
|
63
|
+
| vo_EQHDJ403_16_hutao_05 | 19 | 19 | 0.0100 | 0.0100 | 0.0453 | ✅ |
|
|
64
|
+
| vo_YQEQ203_8_klee_11a | 14 | 14 | 0.0000 | 0.0000 | 0.0001 | ✅ |
|
|
65
|
+
| vo_ayaka_life_less30_teammat | 6 | 6 | 0.0000 | 0.0000 | 0.0003 | ✅ |
|
|
66
|
+
| vo_furina_battle_skill1_02 | 4 | 4 | 0.0000 | 0.0000 | 0.0002 | ✅ |
|
|
67
|
+
| vo_nahida_battle_skill3_03 | 5 | 5 | 0.0000 | 0.0000 | 0.0004 | ✅ |
|
|
68
|
+
| vo_AYLQ003_5_ayaka_28 | 27 | 27 | 0.0000 | 0.0000 | 0.0003 | ✅ |
|
|
69
|
+
| vo_SCHJEQ001_8_ayaka_06 | 6 | 6 | 0.0000 | 0.0000 | 0.0001 | ✅ |
|
|
70
|
+
|
|
71
|
+
**fp16: 19/20 files pass**
|
|
72
|
+
|
|
73
|
+
## fp16 failure analysis (file: 真不错,那我就在旅途中期待你们的厨艺了。)
|
|
74
|
+
|
|
75
|
+
- Divergence origin: chunk 0 (0.66s), d3pm step t=0.625. Boundary count
|
|
76
|
+
fp32=4 vs fp16=5 from that step onward.
|
|
77
|
+
- Mechanism: at the flipping frame the fp32 soft-boundary probability is
|
|
78
|
+
~0.003 (far below the 0.2 threshold) but fp16 gives ~0.60 (above). The
|
|
79
|
+
frame is a genuine borderline in feature space; fp16 rounding moves the
|
|
80
|
+
pre-sigmoid logits across the decision boundary. Because d3pm is iterative
|
|
81
|
+
(decoded boundaries are re-noised as input to the next step), the single
|
|
82
|
+
flipped boundary persists and splits one 0.21s note into 0.12+0.09s.
|
|
83
|
+
- All other 19 files: sequences identical, max pitch diff 0.0011, typical
|
|
84
|
+
<0.001; the two multi-chunk files with 0.87s onset diff are this same
|
|
85
|
+
chunk-0 effect.
|
|
86
|
+
- Conclusion: fp16 pitch accuracy is well within 0.05 tolerance everywhere;
|
|
87
|
+
the single sequence mismatch is an inherent borderline-decision flip, not a
|
|
88
|
+
porting bug (same weights, same randomness). fp32 is the recommended
|
|
89
|
+
precision for exact torch parity.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Performance benchmark (20 files)
|
|
2
|
+
|
|
3
|
+
Inference time excludes audio loading/slicing (model forward only);
|
|
4
|
+
wall time includes python startup + model load. Peak RSS from getrusage.
|
|
5
|
+
|
|
6
|
+
| engine | inference total (s) | avg per file (s) | wall (s) | peak RSS (MB) |
|
|
7
|
+
|---|---|---|---|---|
|
|
8
|
+
| torch_cpu | 32.19 | 1.609 | 41.6 | 971 |
|
|
9
|
+
| mlx_fp32 | 3.44 | 0.172 | 9.7 | 616 |
|
|
10
|
+
| mlx_fp16 | 4.76 | 0.238 | 7.1 | 441 |
|
|
11
|
+
|
|
12
|
+
- Hardware: MacBook Air M4 (16GB), mlx 0.32.4 / torch 2.14.1 CPU.
|
|
13
|
+
- **mlx fp32 is 9.4x faster than torch CPU** (0.172s vs 1.609s per file avg).
|
|
14
|
+
- fp16 is slower than fp32 here: mlx promotes fp16 weights × fp32 activations to
|
|
15
|
+
fp32 in most ops, so the fp16 run pays conversion cost without faster kernels
|
|
16
|
+
(peak wired memory drops 206→111MB though, RSS 616→441MB).
|
|
17
|
+
- fp16 numerics: 19/20 files align within tolerance (see ALIGNMENT.md); the one
|
|
18
|
+
failure is a single boundary flip at d3pm step t=0.625 on a 0.66s chunk where
|
|
19
|
+
fp16 soft-boundary probabilities diverge from fp32 (0.003 vs 0.60 at the
|
|
20
|
+
flipping frame), amplifying through subsequent d3pm steps.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""gamemlx: pure-MLX inference package for GAME (vocal MIDI extraction)."""
|
|
2
|
+
from .config import GamemlxConfig, load_config
|
|
3
|
+
from .model import GAMEModel, load_model as _load_model_local
|
|
4
|
+
|
|
5
|
+
__all__ = ["GAMEModel", "load_model", "load_config", "GamemlxConfig", "extract"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_model(name_or_path: str = "medium", precision: str = "fp32"):
|
|
9
|
+
"""Load a model by alias (small/medium/large), HF repo id, or local path.
|
|
10
|
+
|
|
11
|
+
Aliases download from HuggingFace (ModelScope fallback) into
|
|
12
|
+
~/.cache/gamemlx on first use. Returns (GAMEModel, lang_map).
|
|
13
|
+
"""
|
|
14
|
+
from .config import load_lang_map
|
|
15
|
+
from .download import download_model
|
|
16
|
+
model_dir = download_model(name_or_path, precision="both")
|
|
17
|
+
model = _load_model_local(model_dir, precision=precision)
|
|
18
|
+
lang_map = load_lang_map(model_dir)
|
|
19
|
+
return model, lang_map
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def extract(model, path, language=None, output_dir=None, tempo=120.0,
|
|
23
|
+
seg_threshold=0.2, seg_radius=0.02, est_threshold=0.2,
|
|
24
|
+
t0=0.0, nsteps=8, rng_seed=None):
|
|
25
|
+
"""High-level API: extract MIDI from a file or directory.
|
|
26
|
+
|
|
27
|
+
`model` is a GAMEModel (see load_model). Mirrors gamemlx.cli extract.
|
|
28
|
+
"""
|
|
29
|
+
import pathlib
|
|
30
|
+
|
|
31
|
+
import mlx.core as mx
|
|
32
|
+
import numpy as np
|
|
33
|
+
|
|
34
|
+
from .cli import _MidiWriter, _parse_filemap
|
|
35
|
+
from .config import load_lang_map
|
|
36
|
+
from .download import CACHE_DIR
|
|
37
|
+
from .slicer import Slicer
|
|
38
|
+
from .utils import get_language_id, load_audio
|
|
39
|
+
|
|
40
|
+
path = pathlib.Path(path)
|
|
41
|
+
filemap = _parse_filemap(path, {".wav", ".flac", ".mp3", ".aac", ".ogg"})
|
|
42
|
+
if output_dir is None:
|
|
43
|
+
output_dir = path if path.is_dir() else path.parent
|
|
44
|
+
output_dir = pathlib.Path(output_dir)
|
|
45
|
+
|
|
46
|
+
sr = model.config.features.audio_sample_rate
|
|
47
|
+
slicer = Slicer(sr=sr, threshold=-40., min_length=1000, min_interval=200,
|
|
48
|
+
max_sil_kept=100)
|
|
49
|
+
radius_frames = round(seg_radius / model.timestep)
|
|
50
|
+
ts = mx.array([t0 + i * (1 - t0) / nsteps for i in range(nsteps)],
|
|
51
|
+
dtype=mx.float32)
|
|
52
|
+
|
|
53
|
+
writer = _MidiWriter(output_dir, tempo)
|
|
54
|
+
for key, filepath in filemap.items():
|
|
55
|
+
lang_map = None # language resolved by caller knowledge; default id 0
|
|
56
|
+
language_id = get_language_id(language, lang_map if language else None)
|
|
57
|
+
wav = load_audio(filepath, sr=sr)
|
|
58
|
+
for chunk in slicer.slice(wav):
|
|
59
|
+
cw = np.asarray(chunk["waveform"], dtype=np.float32)
|
|
60
|
+
duration = cw.shape[0] / sr
|
|
61
|
+
rng = np.random.default_rng(rng_seed) if rng_seed is not None else None
|
|
62
|
+
dur, pres, sc = model(
|
|
63
|
+
mx.array(cw[None, :]), mx.array([[duration]], dtype=mx.float32),
|
|
64
|
+
boundary_threshold=seg_threshold, boundary_radius=radius_frames,
|
|
65
|
+
score_threshold=est_threshold, language=mx.array([language_id]),
|
|
66
|
+
t=ts, rng_matrices=rng,
|
|
67
|
+
)
|
|
68
|
+
mx.eval(dur, pres, sc)
|
|
69
|
+
writer.add_chunk(key, chunk["offset"], duration,
|
|
70
|
+
np.asarray(dur[0]), np.asarray(sc[0]),
|
|
71
|
+
np.asarray(pres[0]))
|
|
72
|
+
outs = [writer.flush(key) for key in list(filemap.keys())]
|
|
73
|
+
return outs
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""gamemlx CLI: extract MIDI from audio, mirroring GAME/infer.py extract."""
|
|
2
|
+
import glob
|
|
3
|
+
import json
|
|
4
|
+
import pathlib
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from .config import load_lang_map
|
|
9
|
+
from .download import download_model
|
|
10
|
+
from .model import load_model
|
|
11
|
+
from .slicer import Slicer
|
|
12
|
+
from .utils import get_language_id, load_audio
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _validate_exts(ctx, param, value):
|
|
16
|
+
return {"." + ext.strip().lower() for ext in value.split(",")}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _validate_output_formats(ctx, param, value):
|
|
20
|
+
formats = {fmt.strip().lower() for fmt in value.split(",")}
|
|
21
|
+
supported = {"mid", "txt", "csv"}
|
|
22
|
+
if not formats.issubset(supported):
|
|
23
|
+
raise click.BadParameter(f"Unsupported formats: {formats - supported}.")
|
|
24
|
+
return formats
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_filemap(path: pathlib.Path, exts: set) -> dict[str, pathlib.Path]:
|
|
28
|
+
if path.is_file():
|
|
29
|
+
return {path.name: path}
|
|
30
|
+
files = [f for f in path.rglob("*") if f.is_file() and f.suffix.lower() in exts]
|
|
31
|
+
filemap = {f.relative_to(path).as_posix(): f for f in files}
|
|
32
|
+
if not filemap:
|
|
33
|
+
raise FileNotFoundError(f"No audio files found in directory: {path}")
|
|
34
|
+
return filemap
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _MidiWriter:
|
|
38
|
+
"""Equivalent of GAME SaveCombinedMidiFileCallback (aggregation + sorting)."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, output_dir: pathlib.Path, tempo: float):
|
|
41
|
+
self.output_dir = output_dir
|
|
42
|
+
self.tempo = tempo
|
|
43
|
+
self.notes: dict[str, list] = {}
|
|
44
|
+
|
|
45
|
+
def add_chunk(self, key, offset, length, durations, scores, presence):
|
|
46
|
+
import mlx.core as mx
|
|
47
|
+
import numpy as np
|
|
48
|
+
d = np.asarray(durations)
|
|
49
|
+
s = np.asarray(scores)
|
|
50
|
+
p = np.asarray(presence).astype(bool)
|
|
51
|
+
# exact torch: pad(d,(1,0)).cumsum.clamp(max=length).add(offset)
|
|
52
|
+
note_onset = np.concatenate([[0.0], np.cumsum(d)])[:len(d) + 1]
|
|
53
|
+
note_onset = np.minimum(note_onset, length) + offset
|
|
54
|
+
note_offset = np.minimum(np.cumsum(d), length) + offset
|
|
55
|
+
for onset, off, score, valid in zip(note_onset, note_offset, s, p):
|
|
56
|
+
if off - onset <= 0 or not valid:
|
|
57
|
+
continue
|
|
58
|
+
self.notes.setdefault(key, []).append((onset, off, float(score)))
|
|
59
|
+
|
|
60
|
+
def flush(self, key):
|
|
61
|
+
import mido
|
|
62
|
+
notes = self.notes.pop(key, [])
|
|
63
|
+
notes.sort(key=lambda x: (x[0], x[1], x[2]))
|
|
64
|
+
# clamp overlapping notes like torch callback
|
|
65
|
+
last_time = 0.0
|
|
66
|
+
cleaned = []
|
|
67
|
+
for onset, offset, pitch in notes:
|
|
68
|
+
onset = max(onset, last_time)
|
|
69
|
+
offset = max(offset, onset)
|
|
70
|
+
if offset <= onset:
|
|
71
|
+
continue
|
|
72
|
+
last_time = offset
|
|
73
|
+
cleaned.append((onset, offset, pitch))
|
|
74
|
+
track = mido.MidiTrack()
|
|
75
|
+
track.append(mido.MetaMessage("set_tempo", tempo=mido.bpm2tempo(self.tempo), time=0))
|
|
76
|
+
last_time = 0
|
|
77
|
+
for onset, offset, pitch in cleaned:
|
|
78
|
+
onset_ticks = round(onset * self.tempo * 8)
|
|
79
|
+
offset_ticks = round(offset * self.tempo * 8)
|
|
80
|
+
midi_pitch = round(pitch)
|
|
81
|
+
if offset_ticks <= onset_ticks:
|
|
82
|
+
continue
|
|
83
|
+
track.append(mido.Message("note_on", note=midi_pitch, time=onset_ticks - last_time))
|
|
84
|
+
track.append(mido.Message("note_off", note=midi_pitch, time=offset_ticks - onset_ticks))
|
|
85
|
+
last_time = offset_ticks
|
|
86
|
+
filepath = (self.output_dir / key).with_suffix(".mid")
|
|
87
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
with mido.MidiFile(charset="utf8") as midi_file:
|
|
89
|
+
midi_file.tracks.append(track)
|
|
90
|
+
midi_file.save(filepath)
|
|
91
|
+
return filepath
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@click.group()
|
|
95
|
+
def main():
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@main.command(name="extract", help="Extract MIDI from single or multiple audio files.")
|
|
100
|
+
@click.argument("path", type=click.Path(exists=True, dir_okay=True, file_okay=True,
|
|
101
|
+
readable=True, path_type=pathlib.Path))
|
|
102
|
+
@click.option("-m", "--model", type=str, required=True,
|
|
103
|
+
help="Model alias (small|medium|large, auto-download) or path to a local MLX model directory.")
|
|
104
|
+
@click.option("-l", "--language", type=str, default=None, show_default=False,
|
|
105
|
+
help="Language code for better segmentation if supported.")
|
|
106
|
+
@click.option("--output-formats", type=str, default="mid", show_default=True,
|
|
107
|
+
callback=_validate_output_formats,
|
|
108
|
+
help="Output formats: mid, txt, csv.")
|
|
109
|
+
@click.option("--tempo", type=click.FloatRange(min=0, min_open=True), default=120,
|
|
110
|
+
show_default=True, help="Tempo (BPM) for MIDI files.")
|
|
111
|
+
@click.option("--output-dir", type=click.Path(dir_okay=True, file_okay=False,
|
|
112
|
+
writable=True, path_type=pathlib.Path),
|
|
113
|
+
default=None, help="Directory for results (default: alongside input).")
|
|
114
|
+
@click.option("--precision", type=click.Choice(["fp32", "fp16"]), default="fp32",
|
|
115
|
+
show_default=True, help="Compute precision.")
|
|
116
|
+
@click.option("--seg-threshold", type=float, default=0.2, show_default=True,
|
|
117
|
+
help="Boundary decoding threshold.")
|
|
118
|
+
@click.option("--seg-radius", type=float, default=0.02, show_default=True,
|
|
119
|
+
help="Boundary decoding radius in seconds (divided by timestep, rounded).")
|
|
120
|
+
@click.option("--est-threshold", type=float, default=0.2, show_default=True,
|
|
121
|
+
help="Presence threshold.")
|
|
122
|
+
@click.option("--t0", type=float, default=0.0, show_default=True, help="D3PM start T.")
|
|
123
|
+
@click.option("--nsteps", type=int, default=8, show_default=True, help="D3PM steps.")
|
|
124
|
+
@click.option("--input-formats", type=str, default="wav,flac,mp3,aac,ogg",
|
|
125
|
+
show_default=True, callback=_validate_exts)
|
|
126
|
+
@click.option("--rng-seed", type=int, default=None,
|
|
127
|
+
help="numpy seed for d3pm randomness (default: nondeterministic).")
|
|
128
|
+
@click.pass_context
|
|
129
|
+
def extract(ctx, path, model, language, output_formats, tempo, output_dir, precision,
|
|
130
|
+
seg_threshold, seg_radius, est_threshold, t0, nsteps, input_formats, rng_seed):
|
|
131
|
+
import time
|
|
132
|
+
|
|
133
|
+
import mlx.core as mx
|
|
134
|
+
import numpy as np
|
|
135
|
+
|
|
136
|
+
filemap = _parse_filemap(path, input_formats)
|
|
137
|
+
if output_dir is None:
|
|
138
|
+
output_dir = path if path.is_dir() else path.parent
|
|
139
|
+
|
|
140
|
+
t_start = time.time()
|
|
141
|
+
# -m accepts alias (small/medium/large -> auto-download), HF repo id, or local dir
|
|
142
|
+
model_arg = str(model)
|
|
143
|
+
if pathlib.Path(model_arg).is_dir():
|
|
144
|
+
model_dir = pathlib.Path(model_arg)
|
|
145
|
+
else:
|
|
146
|
+
model_dir = download_model(model_arg, precision="both")
|
|
147
|
+
m = load_model(model_dir, precision=precision)
|
|
148
|
+
sr = m.config.features.audio_sample_rate
|
|
149
|
+
timestep = m.timestep
|
|
150
|
+
lang_map = load_lang_map(model_dir)
|
|
151
|
+
language_id = get_language_id(language, lang_map)
|
|
152
|
+
|
|
153
|
+
slicer = Slicer(sr=sr, threshold=-40., min_length=1000, min_interval=200,
|
|
154
|
+
max_sil_kept=100)
|
|
155
|
+
radius_frames = round(seg_radius / timestep)
|
|
156
|
+
|
|
157
|
+
# d3pm t values: game ValidationConfig.d3pm_sample_ts_resolved
|
|
158
|
+
ts = [t0 + i * (1 - t0) / nsteps for i in range(nsteps)]
|
|
159
|
+
ts_mx = mx.array(ts, dtype=mx.float32)
|
|
160
|
+
|
|
161
|
+
writers = {}
|
|
162
|
+
if "mid" in output_formats:
|
|
163
|
+
writers["mid"] = _MidiWriter(output_dir, tempo)
|
|
164
|
+
|
|
165
|
+
n_chunks = 0
|
|
166
|
+
for key, filepath in filemap.items():
|
|
167
|
+
wav = load_audio(filepath, sr=sr)
|
|
168
|
+
chunks = slicer.slice(wav)
|
|
169
|
+
for chunk in chunks:
|
|
170
|
+
chunk_wav = np.asarray(chunk["waveform"], dtype=np.float32)
|
|
171
|
+
duration = chunk_wav.shape[0] / sr
|
|
172
|
+
rng = np.random.default_rng(rng_seed) if rng_seed is not None else None
|
|
173
|
+
dur, pres, sc = m(
|
|
174
|
+
mx.array(chunk_wav[None, :]),
|
|
175
|
+
mx.array([[duration]], dtype=mx.float32),
|
|
176
|
+
boundary_threshold=seg_threshold,
|
|
177
|
+
boundary_radius=radius_frames,
|
|
178
|
+
score_threshold=est_threshold,
|
|
179
|
+
language=mx.array([language_id]),
|
|
180
|
+
t=ts_mx,
|
|
181
|
+
rng_matrices=rng,
|
|
182
|
+
)
|
|
183
|
+
mx.eval(dur, pres, sc)
|
|
184
|
+
for w in writers.values():
|
|
185
|
+
w.add_chunk(key, chunk["offset"], duration,
|
|
186
|
+
np.asarray(dur[0]), np.asarray(sc[0]), np.asarray(pres[0]))
|
|
187
|
+
n_chunks += 1
|
|
188
|
+
|
|
189
|
+
for key in list(filemap.keys()):
|
|
190
|
+
for w in writers.values():
|
|
191
|
+
f = w.flush(key)
|
|
192
|
+
if "mid" in writers and w is writers["mid"]:
|
|
193
|
+
click.echo(f"Saved MIDI file: {f.as_posix()}")
|
|
194
|
+
click.echo(f"Done: {len(filemap)} file(s), {n_chunks} chunk(s), "
|
|
195
|
+
f"total {time.time() - t_start:.1f}s ({precision}).")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
main()
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Model and inference configuration, mirroring GAME config.yaml structure.
|
|
2
|
+
|
|
3
|
+
Reference: GAME lib/config/schema.py (ModelConfig / InferenceConfig)
|
|
4
|
+
"""
|
|
5
|
+
import dataclasses
|
|
6
|
+
import json
|
|
7
|
+
import pathlib
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclasses.dataclass
|
|
12
|
+
class FeaturesConfig:
|
|
13
|
+
audio_sample_rate: int = 44100
|
|
14
|
+
hop_size: int = 441
|
|
15
|
+
fft_size: int = 2048
|
|
16
|
+
win_size: int = 2048
|
|
17
|
+
n_mels: int = 80
|
|
18
|
+
fmin: float = 0.0
|
|
19
|
+
fmax: float = 8000.0
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def timestep(self) -> float:
|
|
23
|
+
return self.hop_size / self.audio_sample_rate
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclasses.dataclass
|
|
27
|
+
class BackbonesConfig:
|
|
28
|
+
cls: str
|
|
29
|
+
kwargs: dict[str, Any] = dataclasses.field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclasses.dataclass
|
|
33
|
+
class ModelConfigData:
|
|
34
|
+
mode: str = "d3pm"
|
|
35
|
+
use_languages: bool = True
|
|
36
|
+
num_languages: int = 127
|
|
37
|
+
region_cycle_len: int = 3
|
|
38
|
+
in_dim: int = 80
|
|
39
|
+
embedding_dim: int = 256
|
|
40
|
+
estimator_out_dim: int = 257
|
|
41
|
+
encoder: BackbonesConfig = None
|
|
42
|
+
segmenter: BackbonesConfig = None
|
|
43
|
+
estimator: BackbonesConfig = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclasses.dataclass
|
|
47
|
+
class InferenceConfigData:
|
|
48
|
+
midi_min: float = 0.0
|
|
49
|
+
midi_max: float = 128.0
|
|
50
|
+
midi_num_bins: int = 257
|
|
51
|
+
midi_std: float = 0.5
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclasses.dataclass
|
|
55
|
+
class GamemlxConfig:
|
|
56
|
+
features: FeaturesConfig
|
|
57
|
+
model: ModelConfigData
|
|
58
|
+
inference: InferenceConfigData
|
|
59
|
+
raw: dict = dataclasses.field(default_factory=dict, repr=False)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def timestep(self) -> float:
|
|
63
|
+
return self.features.timestep
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_config(path: str | pathlib.Path) -> GamemlxConfig:
|
|
67
|
+
"""Load config.yaml (GAME format: model: / inference: sections)."""
|
|
68
|
+
import yaml
|
|
69
|
+
|
|
70
|
+
path = pathlib.Path(path)
|
|
71
|
+
with open(path, "r", encoding="utf8") as f:
|
|
72
|
+
raw = yaml.safe_load(f)
|
|
73
|
+
|
|
74
|
+
m = raw["model"]
|
|
75
|
+
i = raw["inference"]
|
|
76
|
+
feats = i["features"]
|
|
77
|
+
spec = feats["spectrogram"]
|
|
78
|
+
features = FeaturesConfig(
|
|
79
|
+
audio_sample_rate=feats["audio_sample_rate"],
|
|
80
|
+
hop_size=feats["hop_size"],
|
|
81
|
+
fft_size=feats["fft_size"],
|
|
82
|
+
win_size=feats["win_size"],
|
|
83
|
+
n_mels=spec["num_bins"],
|
|
84
|
+
fmin=float(spec["fmin"]),
|
|
85
|
+
fmax=float(spec["fmax"]),
|
|
86
|
+
)
|
|
87
|
+
model = ModelConfigData(
|
|
88
|
+
mode=m["mode"],
|
|
89
|
+
use_languages=m["use_languages"],
|
|
90
|
+
num_languages=m["num_languages"],
|
|
91
|
+
region_cycle_len=m["region_cycle_len"],
|
|
92
|
+
in_dim=m["in_dim"],
|
|
93
|
+
embedding_dim=m["embedding_dim"],
|
|
94
|
+
estimator_out_dim=m["estimator_out_dim"],
|
|
95
|
+
encoder=BackbonesConfig(cls=m["encoder"]["cls"], kwargs=dict(m["encoder"]["kwargs"])),
|
|
96
|
+
segmenter=BackbonesConfig(cls=m["segmenter"]["cls"], kwargs=dict(m["segmenter"]["kwargs"])),
|
|
97
|
+
estimator=BackbonesConfig(cls=m["estimator"]["cls"], kwargs=dict(m["estimator"]["kwargs"])),
|
|
98
|
+
)
|
|
99
|
+
inference = InferenceConfigData(
|
|
100
|
+
midi_min=i["midi_min"],
|
|
101
|
+
midi_max=i["midi_max"],
|
|
102
|
+
midi_num_bins=i["midi_num_bins"],
|
|
103
|
+
midi_std=i["midi_std"],
|
|
104
|
+
)
|
|
105
|
+
return GamemlxConfig(features=features, model=model, inference=inference, raw=raw)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def load_lang_map(model_dir: str | pathlib.Path) -> dict[str, int] | None:
|
|
109
|
+
p = pathlib.Path(model_dir) / "lang_map.json"
|
|
110
|
+
if not p.exists():
|
|
111
|
+
return None
|
|
112
|
+
with open(p, "r", encoding="utf8") as f:
|
|
113
|
+
return json.load(f)
|