gamemlx 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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
gamemlx/cli.py ADDED
@@ -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()
gamemlx/config.py ADDED
@@ -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)
gamemlx/d3pm.py ADDED
@@ -0,0 +1,43 @@
1
+ """D3PM schedule and boundary removal, aligned with GAME modules/d3pm.py.
2
+
3
+ Randomness: torch uses torch.rand_like (torch RNG). For exact alignment the
4
+ caller can inject rng matrices (same uniform values used on both the torch
5
+ side via monkeypatching and here). Default: numpy PCG64 generator.
6
+ """
7
+ import mlx.core as mx
8
+ import numpy as np
9
+
10
+
11
+ def d3pm_time_schedule(t: mx.array) -> mx.array:
12
+ # p = (1 + cos(t * pi)) / 2
13
+ return (1.0 + mx.cos(t * np.pi)) / 2.0
14
+
15
+
16
+ def remove_boundaries(boundaries: mx.array, p: mx.array, rnd: mx.array | None = None) -> mx.array:
17
+ """boundaries [..., T] bool; p [...]; rnd optional injected uniform [boundary shape]."""
18
+ q = 1.0 - p[..., None]
19
+ if rnd is None:
20
+ shape = boundaries.shape
21
+ rnd_np = np.random.default_rng().random(shape, dtype=np.float32)
22
+ rnd = mx.array(rnd_np)
23
+ return (rnd <= q) & boundaries
24
+
25
+
26
+ def remove_mutable_boundaries(boundaries: mx.array, immutable: mx.array, p: mx.array,
27
+ rnd: mx.array | None = None) -> mx.array:
28
+ """torch remove_mutable_boundaries; rnd is the uniform matrix used for the
29
+ mutable-boundary removal draw (shape == boundaries shape)."""
30
+ boundaries_mutable = boundaries & ~immutable
31
+ n = boundaries.astype(mx.float32).sum(axis=-1)
32
+ m = boundaries_mutable.astype(mx.float32).sum(axis=-1)
33
+ P = mx.minimum(n * p / (m + 1e-8), 1.0)
34
+ boundaries_mutable_remain = remove_boundaries(boundaries_mutable, P, rnd=rnd)
35
+ return boundaries_mutable_remain | immutable
36
+
37
+
38
+ def make_rng_matrices(rng: np.random.Generator, shapes: list[tuple]) -> list[mx.array]:
39
+ """Pre-generate uniform random matrices for a whole d3pm loop (step-major)."""
40
+ out = []
41
+ for shape in shapes:
42
+ out.append(mx.array(rng.random(shape, dtype=np.float32)))
43
+ return out
gamemlx/decoding.py ADDED
@@ -0,0 +1,60 @@
1
+ """Decoding functions aligned with GAME modules/decoding.py."""
2
+ import math
3
+
4
+ import mlx.core as mx
5
+
6
+
7
+ def find_local_extremum(x: mx.array, threshold: float | None = None, radius: int = 2,
8
+ maxima: bool = True) -> mx.array:
9
+ """x: [..., T]. argmax within window [i-r, i+r] == radius (first occurrence),
10
+ matching torch unfold+argmax semantics."""
11
+ T = x.shape[-1]
12
+ infinite = float("+inf") if maxima else float("-inf")
13
+ x_pad = mx.pad(x, [(0, 0)] * (x.ndim - 1) + [(radius, radius)],
14
+ constant_values=infinite)
15
+ wins = mx.stack([x_pad[..., i:i + T] for i in range(2 * radius + 1)], axis=0) # [W, ..., T]
16
+ if maxima:
17
+ points = mx.argmax(wins, axis=0) == radius
18
+ if threshold is not None:
19
+ points = points & (x >= threshold)
20
+ else:
21
+ points = mx.argmin(wins, axis=0) == radius
22
+ if threshold is not None:
23
+ points = points & (x <= threshold)
24
+ return points
25
+
26
+
27
+ def decode_soft_boundaries(boundaries: mx.array, barriers: mx.array = None,
28
+ mask: mx.array = None, threshold: float = 0.5,
29
+ radius: int = 2) -> mx.array:
30
+ if mask is not None:
31
+ boundaries = mx.where(mask, boundaries, float("+inf"))
32
+ if barriers is not None:
33
+ boundaries = mx.where(barriers, float("+inf"), boundaries)
34
+ maxima = find_local_extremum(boundaries, threshold=threshold, radius=radius, maxima=True)
35
+ if mask is not None:
36
+ maxima = maxima & mask
37
+ return maxima
38
+
39
+
40
+ def decode_gaussian_blurred_probs(probs: mx.array, min_val: float, max_val: float,
41
+ deviation: float, threshold: float = 0.1):
42
+ """probs: [..., N] -> values [...], presence [...]. Aligned with torch version."""
43
+ ndim = probs.ndim
44
+ N = probs.shape[-1]
45
+ width = math.ceil(deviation / (max_val - min_val) * (N - 1))
46
+ idx = mx.arange(N) # [N]
47
+ center_values = mx.linspace(min_val, max_val, N) # torch.linspace(float32)
48
+ centers = mx.argmax(probs, axis=-1, keepdims=True)
49
+ start = mx.maximum(centers - width, 0)
50
+ end = mx.minimum(centers + width + 1, N)
51
+ # broadcast idx to probs shape: [1,...,N]
52
+ bshape = (1,) * (ndim - 1) + (N,)
53
+ idx_b = mx.broadcast_to(idx.reshape(bshape), probs.shape)
54
+ idx_masks = (idx_b >= start) & (idx_b < end)
55
+ weights = probs * idx_masks
56
+ product_sum = mx.sum(weights * center_values, axis=-1)
57
+ weight_sum = mx.sum(weights, axis=-1)
58
+ values = product_sum / (weight_sum + 1e-8)
59
+ presence = mx.max(probs, axis=-1) >= threshold
60
+ return values, presence
gamemlx/download.py ADDED
@@ -0,0 +1,103 @@
1
+ """Model download: HuggingFace primary, ModelScope fallback, local cache.
2
+
3
+ Sources (verified reachable):
4
+ primary HF: baicai1145/GAME-MLX-{small,medium,large}
5
+ fallback ModelScope: https://modelscope.cn/models/baicai1145/GAME-MLX-{...}
6
+
7
+ Files per model: config.yaml, lang_map.json,
8
+ weights.safetensors (fp32), weights.fp16.safetensors (fp16).
9
+ fp16 weights are fetched lazily; fp32 only when needed.
10
+ """
11
+ import os
12
+ import pathlib
13
+
14
+ HF_REPOS = {
15
+ "small": "baicai1145/GAME-MLX-small",
16
+ "medium": "baicai1145/GAME-MLX-medium",
17
+ "large": "baicai1145/GAME-MLX-large",
18
+ }
19
+ MODELSCOPE_BASE = "https://www.modelscope.cn/models/{repo}/resolve/master/{file}"
20
+ REQUIRED_FILES = ["config.yaml", "lang_map.json"]
21
+
22
+ CACHE_DIR = pathlib.Path(os.environ.get("GAMEMLX_CACHE",
23
+ pathlib.Path.home() / ".cache/gamemlx"))
24
+
25
+
26
+ def weights_file(precision: str) -> str:
27
+ return "weights.fp16.safetensors" if precision == "fp16" else "weights.safetensors"
28
+
29
+
30
+ def _hf_download(repo_id: str, filename: str, cache_dir: pathlib.Path) -> pathlib.Path:
31
+ from huggingface_hub import hf_hub_download
32
+ return pathlib.Path(hf_hub_download(
33
+ repo_id=repo_id, filename=filename, cache_dir=str(cache_dir / "hf"),
34
+ ))
35
+
36
+
37
+ def _modelscope_download(repo_id: str, filename: str, dest: pathlib.Path) -> pathlib.Path:
38
+ import urllib.request
39
+ url = MODELSCOPE_BASE.format(repo=repo_id, file=filename)
40
+ dest.parent.mkdir(parents=True, exist_ok=True)
41
+ tmp = dest.with_suffix(dest.suffix + ".part")
42
+ with urllib.request.urlopen(url) as r, open(tmp, "wb") as f:
43
+ while True:
44
+ chunk = r.read(1 << 20)
45
+ if not chunk:
46
+ break
47
+ f.write(chunk)
48
+ tmp.rename(dest)
49
+ return dest
50
+
51
+
52
+ def download_model(name_or_path: str, precision: str = "fp16") -> pathlib.Path:
53
+ """Download (or reuse cached) model; returns the local model directory.
54
+
55
+ name_or_path: 'small' | 'medium' | 'large' | HF repo id | local path.
56
+ precision: which weights to fetch ('fp16' default, 'fp32' optional, 'both').
57
+ """
58
+ p = pathlib.Path(name_or_path).expanduser()
59
+ if (p / "config.yaml").is_file():
60
+ return p # local dir
61
+ if name_or_path not in HF_REPOS:
62
+ # allow full HF repo ids like 'baicai1145/GAME-MLX-medium'
63
+ alias = name_or_path.split("/")[-1].lower()
64
+ if alias not in HF_REPOS.values():
65
+ raise ValueError(
66
+ f"Unknown model '{name_or_path}'. Aliases: {list(HF_REPOS)}, "
67
+ "or pass a local directory / full repo id.")
68
+ repo_id = name_or_path
69
+ else:
70
+ alias = name_or_path
71
+ repo_id = HF_REPOS[alias]
72
+
73
+ model_dir = CACHE_DIR / alias
74
+ model_dir.mkdir(parents=True, exist_ok=True)
75
+
76
+ def ensure(file: str) -> pathlib.Path:
77
+ target = model_dir / file
78
+ if target.is_file() and target.stat().st_size > 0:
79
+ return target
80
+ try:
81
+ got = _hf_download(repo_id, file, CACHE_DIR)
82
+ if not target.is_file():
83
+ try:
84
+ os.link(got, target)
85
+ except OSError:
86
+ import shutil
87
+ shutil.copyfile(got, target)
88
+ return target
89
+ except Exception as e:
90
+ try:
91
+ return _modelscope_download(repo_id, file, target)
92
+ except Exception as e2:
93
+ raise RuntimeError(
94
+ f"Failed to fetch {file} for {alias}: HF: {e}; ModelScope: {e2}"
95
+ ) from e2
96
+
97
+ for f in REQUIRED_FILES:
98
+ ensure(f)
99
+ wanted = ["weights.fp16.safetensors", "weights.safetensors"] \
100
+ if precision == "both" else [weights_file(precision)]
101
+ for f in wanted:
102
+ ensure(f)
103
+ return model_dir
gamemlx/features.py ADDED
@@ -0,0 +1,62 @@
1
+ """MelSpectrogram feature extraction numerically identical to GAME
2
+ StretchableMelSpectrogram (lib/feature/mel.py) at inference defaults:
3
+
4
+ - librosa mel filterbank (sr, n_fft=2048, n_mels=80, fmin, fmax, htk=False, norm='slaney')
5
+ - torch hann_window(2048): PERIODIC window (symmetric=False), divided by nothing
6
+ - reflect pad (win-hop)//2 and (win-hop+1)//2, center=False framing
7
+ - rFFT -> magnitude -> mel_basis @ spec -> log(clamp(., min=1e-5))
8
+
9
+ STFT computed with mlx rfft on strided frames (matches torch.stft numerics
10
+ within float32 error; validated in alignment tests).
11
+ """
12
+ import numpy as np
13
+
14
+ import mlx.core as mx
15
+
16
+
17
+ def _librosa_mel(sr, n_fft, n_mels, fmin, fmax):
18
+ import librosa.filters
19
+ return librosa.filters.mel(sr=sr, n_fft=n_fft, n_mels=n_mels,
20
+ fmin=fmin, fmax=fmax, htk=False, norm="slaney")
21
+
22
+
23
+ class MelSpectrogram:
24
+ def __init__(self, sample_rate=44100, n_mels=80, n_fft=2048, win_length=2048,
25
+ hop_length=441, fmin=0.0, fmax=8000.0, clip_val=1e-5):
26
+ self.sample_rate = sample_rate
27
+ self.n_fft = n_fft
28
+ self.win_size = win_length
29
+ self.hop_length = hop_length
30
+ self.fmin = fmin
31
+ self.fmax = fmax
32
+ self.clip_val = clip_val
33
+ self.n_mels = n_mels
34
+ mel_basis = _librosa_mel(sample_rate, n_fft, n_mels, fmin, fmax)
35
+ self.mel_basis_np = mel_basis.astype(np.float32)
36
+ self.mel_basis = mx.array(self.mel_basis_np)
37
+ # torch hann_window(w, periodic=True) == 0.5 - 0.5*cos(2*pi*i/w), i in [0, w)
38
+ i = np.arange(win_length, dtype=np.float64)
39
+ window_np = (0.5 - 0.5 * np.cos(2.0 * np.pi * i / win_length)).astype(np.float32)
40
+ self.window = mx.array(window_np)
41
+
42
+ def __call__(self, y: mx.array) -> mx.array:
43
+ """y: [B, L] -> mel [B, n_mels, T] (like torch StretchableMelSpectrogram)."""
44
+ win, hop, n_fft = self.win_size, self.hop_length, self.n_fft
45
+ pad_l = (win - hop) // 2
46
+ pad_r = (win - hop + 1) // 2
47
+ y = mx.pad(y, [(0, 0), (pad_l, pad_r)], mode="reflect")
48
+ B, L = y.shape
49
+ n_frames = 1 + (L - win) // hop
50
+ if n_frames <= 0:
51
+ raise ValueError(f"waveform too short for one frame: {L} samples")
52
+ # frame via strided gather
53
+ starts = mx.arange(n_frames) * hop # [T]
54
+ idx = starts[:, None] + mx.arange(win)[None, :] # [T, win]
55
+ idx = mx.minimum(idx, L - 1) # torch would error; guard not triggered for valid lengths
56
+ frames = y[:, idx.reshape(-1)].reshape(B, n_frames, win) # [B, T, win]
57
+ frames = frames * self.window
58
+ spec = mx.fft.rfft(frames, n=n_fft, axis=-1) # [B, T, n_fft//2+1] complex64
59
+ mag = mx.abs(spec) # == torch complex abs
60
+ mel = self.mel_basis @ mag.transpose(0, 2, 1) # [B, mels, T]
61
+ mel = mx.log(mx.maximum(mel, self.clip_val))
62
+ return mel