xlt99 commited on
Commit
c72d79d
·
verified ·
1 Parent(s): 5e8aca3

Release custom music inference

Browse files
Files changed (1) hide show
  1. inference.py +477 -0
inference.py ADDED
@@ -0,0 +1,477 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import os
3
+ import pickle
4
+ import random
5
+ import sys
6
+ import types
7
+ from pathlib import Path
8
+
9
+ import librosa
10
+ import numpy as np
11
+ import soundfile as sf
12
+ import torch
13
+
14
+
15
+ FPS = 30
16
+ HOP_LENGTH = 512
17
+ SAMPLE_RATE = FPS * HOP_LENGTH
18
+ FEATURE_DIM = 35
19
+ MOTION_DIM = 151
20
+ DEFAULT_DURATION = 32.0
21
+ MAX_DURATION = 60.0
22
+
23
+ GENRES = (
24
+ "Dai",
25
+ "ShenYun",
26
+ "Wei",
27
+ "Korean",
28
+ "Urban",
29
+ "Hiphop",
30
+ "Popping",
31
+ "Miao",
32
+ "HanTang",
33
+ "Breaking",
34
+ "Kun",
35
+ "Locking",
36
+ "Jazz",
37
+ "Choreography",
38
+ "Chinese",
39
+ "DunHuang",
40
+ )
41
+
42
+ # These are the exact per-channel bounds used by the training-set condition
43
+ # normalizer. Keeping them here makes inference independent of the 3.4 GB
44
+ # cached training dataset.
45
+ COND_MIN = np.asarray(
46
+ [
47
+ 0.0,
48
+ -1131.3709716796875,
49
+ -237.15911865234375,
50
+ -171.30734252929688,
51
+ -92.51953125,
52
+ -113.9908447265625,
53
+ -83.63716125488281,
54
+ -91.29580688476562,
55
+ -69.15321350097656,
56
+ -77.8322525024414,
57
+ -69.10548400878906,
58
+ -87.16200256347656,
59
+ -61.735443115234375,
60
+ -80.8205795288086,
61
+ -58.1674690246582,
62
+ -80.16529083251953,
63
+ -65.69412994384766,
64
+ -69.32086181640625,
65
+ -64.44036865234375,
66
+ -68.75288391113281,
67
+ -62.361083984375,
68
+ 0.0,
69
+ 0.0,
70
+ 0.0,
71
+ 0.0,
72
+ 0.0,
73
+ 0.0,
74
+ 0.0,
75
+ 0.0,
76
+ 0.0,
77
+ 0.0,
78
+ 0.0,
79
+ 0.0,
80
+ 0.0,
81
+ 0.0,
82
+ ],
83
+ dtype=np.float32,
84
+ )
85
+
86
+ COND_MAX = np.asarray(
87
+ [
88
+ 40.581703186035156,
89
+ 155.65695190429688,
90
+ 289.90618896484375,
91
+ 153.26834106445312,
92
+ 148.48130798339844,
93
+ 106.52911376953125,
94
+ 104.7796401977539,
95
+ 81.25057983398438,
96
+ 83.61593627929688,
97
+ 83.17398071289062,
98
+ 120.94718933105469,
99
+ 74.80978393554688,
100
+ 90.64946746826172,
101
+ 65.08392333984375,
102
+ 74.385986328125,
103
+ 69.89390563964844,
104
+ 76.94829559326172,
105
+ 65.66349029541016,
106
+ 83.71824645996094,
107
+ 69.2318344116211,
108
+ 74.71946716308594,
109
+ 0.9658729434013367,
110
+ 0.9479968547821045,
111
+ 0.9964158535003662,
112
+ 0.9637431502342224,
113
+ 0.9991030693054199,
114
+ 0.9481787085533142,
115
+ 0.9748934507369995,
116
+ 1.0,
117
+ 0.9787861704826355,
118
+ 0.9942631721496582,
119
+ 0.9858856797218323,
120
+ 0.9759241342544556,
121
+ 1.0,
122
+ 1.0,
123
+ ],
124
+ dtype=np.float32,
125
+ )
126
+
127
+
128
+ def parse_genre(value):
129
+ value = str(value).strip()
130
+ if value.isdigit():
131
+ genre_id = int(value)
132
+ if 0 <= genre_id < len(GENRES):
133
+ return genre_id
134
+
135
+ lowered = value.casefold()
136
+ for genre_id, genre_name in enumerate(GENRES):
137
+ if genre_name.casefold() == lowered:
138
+ return genre_id
139
+
140
+ valid = ", ".join(f"{index}:{name}" for index, name in enumerate(GENRES))
141
+ raise argparse.ArgumentTypeError(f"Unknown genre '{value}'. Choose one of: {valid}")
142
+
143
+
144
+ def load_audio_clip(audio_path, start, duration):
145
+ audio, _ = librosa.load(audio_path, sr=SAMPLE_RATE, mono=True)
146
+ start_sample = round(start * SAMPLE_RATE)
147
+ frame_count = round(duration * FPS)
148
+ sample_count = frame_count * HOP_LENGTH
149
+ end_sample = start_sample + sample_count
150
+
151
+ if start_sample < 0:
152
+ raise ValueError("--start must be non-negative")
153
+ if end_sample > len(audio):
154
+ available = max(0.0, len(audio) / SAMPLE_RATE - start)
155
+ raise ValueError(
156
+ f"Input audio is too short: requested {duration:.2f}s from "
157
+ f"{start:.2f}s, but only {available:.2f}s is available."
158
+ )
159
+
160
+ tempo_audio, tempo_sample_rate = librosa.load(
161
+ audio_path,
162
+ sr=22050,
163
+ mono=True,
164
+ offset=start,
165
+ duration=duration,
166
+ )
167
+ return (
168
+ np.asarray(audio[start_sample:end_sample], dtype=np.float32),
169
+ np.asarray(tempo_audio, dtype=np.float32),
170
+ tempo_sample_rate,
171
+ frame_count,
172
+ )
173
+
174
+
175
+ def estimate_tempo(audio, sample_rate):
176
+ tempo = librosa.beat.tempo(y=audio, sr=sample_rate)
177
+ return float(np.asarray(tempo).reshape(-1)[0])
178
+
179
+
180
+ def extract_baseline_features(audio, frame_count, start_bpm=None):
181
+ envelope = librosa.onset.onset_strength(
182
+ y=audio,
183
+ sr=SAMPLE_RATE,
184
+ hop_length=HOP_LENGTH,
185
+ )
186
+ mfcc = librosa.feature.mfcc(
187
+ y=audio,
188
+ sr=SAMPLE_RATE,
189
+ hop_length=HOP_LENGTH,
190
+ n_mfcc=20,
191
+ ).T
192
+ chroma = librosa.feature.chroma_cens(
193
+ y=audio,
194
+ sr=SAMPLE_RATE,
195
+ hop_length=HOP_LENGTH,
196
+ n_chroma=12,
197
+ ).T
198
+
199
+ peak_indices = librosa.onset.onset_detect(
200
+ onset_envelope=envelope,
201
+ sr=SAMPLE_RATE,
202
+ hop_length=HOP_LENGTH,
203
+ )
204
+ peak_onehot = np.zeros_like(envelope, dtype=np.float32)
205
+ peak_onehot[peak_indices] = 1.0
206
+
207
+ _, beat_indices = librosa.beat.beat_track(
208
+ onset_envelope=envelope,
209
+ sr=SAMPLE_RATE,
210
+ hop_length=HOP_LENGTH,
211
+ start_bpm=(
212
+ estimate_tempo(audio, SAMPLE_RATE) if start_bpm is None else start_bpm
213
+ ),
214
+ tightness=100,
215
+ )
216
+ beat_onehot = np.zeros_like(envelope, dtype=np.float32)
217
+ beat_onehot[np.asarray(beat_indices, dtype=np.int64)] = 1.0
218
+
219
+ common_length = min(
220
+ len(envelope),
221
+ len(mfcc),
222
+ len(chroma),
223
+ len(peak_onehot),
224
+ len(beat_onehot),
225
+ )
226
+ if common_length < frame_count:
227
+ raise RuntimeError(
228
+ f"Feature extractor returned {common_length} frames, "
229
+ f"but {frame_count} are required."
230
+ )
231
+
232
+ features = np.concatenate(
233
+ [
234
+ envelope[:frame_count, None],
235
+ mfcc[:frame_count],
236
+ chroma[:frame_count],
237
+ peak_onehot[:frame_count, None],
238
+ beat_onehot[:frame_count, None],
239
+ ],
240
+ axis=-1,
241
+ )
242
+ if features.shape != (frame_count, FEATURE_DIM):
243
+ raise RuntimeError(f"Unexpected audio feature shape: {features.shape}")
244
+ return features.astype(np.float32, copy=False)
245
+
246
+
247
+ def normalize_features(features):
248
+ data_range = COND_MAX - COND_MIN
249
+ if np.any(data_range <= 0):
250
+ raise RuntimeError("Invalid embedded condition-normalization bounds")
251
+ normalized = 2.0 * (features - COND_MIN) / data_range - 1.0
252
+ return np.clip(normalized, -1.0, 1.0).astype(np.float32, copy=False)
253
+
254
+
255
+ def resolve_checkpoint(checkpoint):
256
+ if checkpoint is not None:
257
+ checkpoint = Path(checkpoint).expanduser()
258
+ if not checkpoint.is_file():
259
+ raise FileNotFoundError(f"Checkpoint not found: {checkpoint}")
260
+ return checkpoint.resolve()
261
+
262
+ local_checkpoint = Path("runs/train/uniform2/weights/train-3700.pt")
263
+ if local_checkpoint.is_file():
264
+ return local_checkpoint.resolve()
265
+
266
+ try:
267
+ from huggingface_hub import hf_hub_download
268
+ except ImportError as exc:
269
+ raise RuntimeError(
270
+ "No local checkpoint was found and huggingface_hub is unavailable. "
271
+ "Install the repository requirements or pass --checkpoint."
272
+ ) from exc
273
+
274
+ downloaded = hf_hub_download("xlt99/FlowerDance", "train-3700.pt")
275
+ return Path(downloaded).resolve()
276
+
277
+
278
+ def set_seed(seed):
279
+ random.seed(seed)
280
+ np.random.seed(seed)
281
+ torch.manual_seed(seed)
282
+ if torch.cuda.is_available():
283
+ torch.cuda.manual_seed_all(seed)
284
+
285
+
286
+ def import_edge():
287
+ try:
288
+ import p_tqdm # noqa: F401
289
+ except ImportError:
290
+ compatibility_module = types.ModuleType("p_tqdm")
291
+ compatibility_module.p_map = lambda function, values, **_: list(
292
+ map(function, values)
293
+ )
294
+ sys.modules["p_tqdm"] = compatibility_module
295
+
296
+ from EDGE import EDGE
297
+
298
+ return EDGE
299
+
300
+
301
+ def load_model(checkpoint_path):
302
+ if not torch.cuda.is_available():
303
+ raise RuntimeError("FlowerDance inference requires a CUDA GPU.")
304
+
305
+ EDGE = import_edge()
306
+ model = EDGE(feature_type="baseline", checkpoint_path="")
307
+ checkpoint = torch.load(
308
+ checkpoint_path,
309
+ map_location=model.accelerator.device,
310
+ weights_only=False,
311
+ )
312
+ if "model_state_dict" not in checkpoint or "normalizer" not in checkpoint:
313
+ raise KeyError(
314
+ "Checkpoint must contain 'model_state_dict' and 'normalizer'."
315
+ )
316
+
317
+ unwrapped_model = model.accelerator.unwrap_model(model.model)
318
+ unwrapped_model.load_state_dict(checkpoint["model_state_dict"], strict=True)
319
+ model.normalizer = checkpoint["normalizer"]
320
+ model.eval()
321
+ return model
322
+
323
+
324
+ def generate_motion(model, features, genre_id, output_dir, output_stem, steps):
325
+ device = model.accelerator.device
326
+ condition = torch.from_numpy(features).unsqueeze(0).to(device)
327
+ genre = torch.tensor([genre_id], dtype=torch.long, device=device)
328
+ shape = (1, condition.shape[1], MOTION_DIM)
329
+
330
+ with torch.inference_mode():
331
+ model.flow_matching.render_sample(
332
+ shape,
333
+ condition,
334
+ genre,
335
+ model.normalizer,
336
+ epoch=0,
337
+ render_out=None,
338
+ fk_out=str(output_dir),
339
+ name=[f"{output_stem}.npy"],
340
+ sound=False,
341
+ n_steps=steps,
342
+ )
343
+
344
+ motion_path = output_dir / "0" / f"{output_stem}.pkl"
345
+ if not motion_path.is_file():
346
+ raise RuntimeError(f"Expected motion output was not created: {motion_path}")
347
+
348
+ with motion_path.open("rb") as file:
349
+ motion = pickle.load(file)
350
+ expected_frames = features.shape[0]
351
+ if motion["smpl_poses"].shape != (expected_frames, 72):
352
+ raise RuntimeError(
353
+ "Unexpected generated SMPL pose shape: "
354
+ f"{motion['smpl_poses'].shape}"
355
+ )
356
+ return motion_path
357
+
358
+
359
+ def build_parser():
360
+ parser = argparse.ArgumentParser(
361
+ description="Generate a FlowerDance motion from an uploaded music file."
362
+ )
363
+ parser.add_argument(
364
+ "music",
365
+ type=Path,
366
+ nargs="?",
367
+ help="Input WAV, MP3, FLAC, or OGG file",
368
+ )
369
+ parser.add_argument(
370
+ "--genre",
371
+ type=parse_genre,
372
+ default=parse_genre("Hiphop"),
373
+ help="Genre name or index. Default: Hiphop",
374
+ )
375
+ parser.add_argument(
376
+ "--checkpoint",
377
+ type=Path,
378
+ default=None,
379
+ help="Checkpoint path. Downloads xlt99/FlowerDance when omitted.",
380
+ )
381
+ parser.add_argument(
382
+ "--output-dir",
383
+ type=Path,
384
+ default=Path("inference_outputs"),
385
+ help="Directory for generated motion and the processed audio clip.",
386
+ )
387
+ parser.add_argument(
388
+ "--duration",
389
+ type=float,
390
+ default=DEFAULT_DURATION,
391
+ help=f"Output duration in seconds. Default: {DEFAULT_DURATION:g}",
392
+ )
393
+ parser.add_argument(
394
+ "--start",
395
+ type=float,
396
+ default=0.0,
397
+ help="Start time in the input music, in seconds.",
398
+ )
399
+ parser.add_argument(
400
+ "--steps",
401
+ type=int,
402
+ default=21,
403
+ help="Number of Euler sampling steps. Default: 21",
404
+ )
405
+ parser.add_argument("--seed", type=int, default=42)
406
+ parser.add_argument(
407
+ "--list-genres",
408
+ action="store_true",
409
+ help="Print supported genres and exit.",
410
+ )
411
+ return parser
412
+
413
+
414
+ def main():
415
+ parser = build_parser()
416
+ args = parser.parse_args()
417
+
418
+ if args.list_genres:
419
+ for genre_id, genre_name in enumerate(GENRES):
420
+ print(f"{genre_id:2d} {genre_name}")
421
+ return
422
+
423
+ if args.music is None:
424
+ parser.error("music is required unless --list-genres is used")
425
+
426
+ music_path = args.music.expanduser().resolve()
427
+ if not music_path.is_file():
428
+ parser.error(f"Music file not found: {music_path}")
429
+ if not 0 < args.duration <= MAX_DURATION:
430
+ parser.error(f"--duration must be in (0, {MAX_DURATION:g}] seconds")
431
+ if args.steps < 2:
432
+ parser.error("--steps must be at least 2")
433
+
434
+ output_dir = args.output_dir.expanduser().resolve()
435
+ output_dir.mkdir(parents=True, exist_ok=True)
436
+ output_stem = f"{music_path.stem}_{GENRES[args.genre]}"
437
+
438
+ print(f"Music: {music_path}")
439
+ print(f"Genre: {GENRES[args.genre]} ({args.genre})")
440
+ print(f"Duration: {args.duration:.2f}s from {args.start:.2f}s")
441
+
442
+ audio, tempo_audio, tempo_sample_rate, frame_count = load_audio_clip(
443
+ str(music_path),
444
+ start=args.start,
445
+ duration=args.duration,
446
+ )
447
+ start_bpm = estimate_tempo(tempo_audio, tempo_sample_rate)
448
+ raw_features = extract_baseline_features(audio, frame_count, start_bpm)
449
+ normalized_features = normalize_features(raw_features)
450
+
451
+ audio_output = output_dir / f"{output_stem}.wav"
452
+ feature_output = output_dir / f"{output_stem}_features.npy"
453
+ sf.write(audio_output, audio, SAMPLE_RATE)
454
+ np.save(feature_output, normalized_features)
455
+
456
+ checkpoint_path = resolve_checkpoint(args.checkpoint)
457
+ print(f"Checkpoint: {checkpoint_path}")
458
+ set_seed(args.seed)
459
+ model = load_model(checkpoint_path)
460
+ motion_path = generate_motion(
461
+ model,
462
+ normalized_features,
463
+ args.genre,
464
+ output_dir,
465
+ output_stem,
466
+ args.steps,
467
+ )
468
+
469
+ print(f"Motion: {motion_path}")
470
+ print(f"Audio clip: {audio_output}")
471
+ print(f"Normalized features: {feature_output}")
472
+
473
+
474
+ if __name__ == "__main__":
475
+ # Avoid tokenizer worker processes being created by transitive imports.
476
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
477
+ main()