SaltProphet commited on
Commit
9d9345d
·
verified ·
1 Parent(s): 1576ed9

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +120 -199
app.py CHANGED
@@ -16,7 +16,7 @@ import json
16
  from datetime import datetime
17
  import pyloudnorm as pyln
18
 
19
- # --- OPTIONAL: MIDI IMPORT (Graceful Fail) ---
20
  try:
21
  from basic_pitch.inference import predict_and_save
22
  MIDI_AVAILABLE = True
@@ -24,17 +24,15 @@ except ImportError:
24
  MIDI_AVAILABLE = False
25
  print("WARNING: 'basic-pitch' not installed. MIDI extraction will be disabled.")
26
 
27
- # --- PATCH FOR PILLOW 10.0+ vs MOVIEPY 1.0.3 COMPATIBILITY ---
28
  import PIL.Image
29
  if not hasattr(PIL.Image, 'ANTIALIAS'):
30
  PIL.Image.ANTIALIAS = PIL.Image.LANCZOS
31
- # -------------------------------------------------------------
32
 
33
- # --- Configuration ---
34
  OUTPUT_DIR = Path("nightpulse_output")
35
  TEMP_DIR = Path("temp_processing")
36
 
37
-
38
  # -----------------------------
39
  # Startup Checks
40
  # -----------------------------
@@ -46,55 +44,41 @@ def check_ffmpeg():
46
 
47
  check_ffmpeg()
48
 
49
-
50
  # -----------------------------
51
- # Key Detection Engine
52
  # -----------------------------
53
  def detect_key(audio_path):
54
- """
55
- Estimates key (e.g., 'Cmaj', 'F#min') using Chroma features.
56
- """
57
  try:
58
  y, sr = librosa.load(str(audio_path), sr=None, duration=60)
59
  chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
60
  chroma_vals = np.sum(chroma, axis=1)
61
-
62
- # Krumhansl-Schmuckler Profiles
63
  maj_profile = [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88]
64
  min_profile = [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17]
65
  pitches = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
66
-
67
  best_score = -1
68
  best_key = "Unknown"
69
-
70
  for i in range(12):
71
  p_maj = np.roll(maj_profile, i)
72
  p_min = np.roll(min_profile, i)
73
-
74
  score_maj = np.corrcoef(chroma_vals, p_maj)[0, 1]
75
  score_min = np.corrcoef(chroma_vals, p_min)[0, 1]
76
-
77
  if score_maj > best_score:
78
  best_score = score_maj
79
  best_key = f"{pitches[i]}maj"
80
  if score_min > best_score:
81
  best_score = score_min
82
  best_key = f"{pitches[i]}min"
83
-
84
  return best_key
85
  except Exception:
86
  return "Unknown"
87
 
88
-
89
  # -----------------------------
90
- # Cloud Import
91
  # -----------------------------
92
  def download_from_url(url):
93
  if not url: return None
94
- print(f"Fetching URL: {url}")
95
  if TEMP_DIR.exists(): shutil.rmtree(TEMP_DIR, ignore_errors=True)
96
  TEMP_DIR.mkdir(parents=True, exist_ok=True)
97
-
98
  ydl_opts = {
99
  "format": "bestaudio/best",
100
  "outtmpl": str(TEMP_DIR / "%(title)s.%(ext)s"),
@@ -106,10 +90,6 @@ def download_from_url(url):
106
  filename = ydl.prepare_filename(info)
107
  return str(Path(filename).with_suffix(".wav"))
108
 
109
-
110
- # -----------------------------
111
- # File Helpers
112
- # -----------------------------
113
  def safe_copy_to_temp(audio_file: str) -> str:
114
  src = Path(audio_file)
115
  TEMP_DIR.mkdir(parents=True, exist_ok=True)
@@ -127,106 +107,61 @@ def ensure_wav(input_path: str) -> str:
127
  AudioSegment.from_file(str(p)).export(str(out), format="wav")
128
  return str(out)
129
 
130
-
131
  # -----------------------------
132
  # Demucs + MIDI
133
  # -----------------------------
134
  def run_demucs(cmd):
135
  p = subprocess.run(cmd, capture_output=True, text=True)
136
- if p.returncode != 0:
137
- raise gr.Error(f"Demucs Error:\n{p.stderr[-2000:]}")
138
  return p.stdout
139
 
140
  def extract_midi(audio_path, out_path):
141
- """Uses basic-pitch to convert audio to MIDI."""
142
  if not MIDI_AVAILABLE: return
143
- # basic-pitch expects output directory, not filename
144
  out_dir = out_path.parent
145
  name = out_path.stem
146
- predict_and_save(
147
- [str(audio_path)],
148
- str(out_dir),
149
- True, False, False, # save_midi, save_model_outputs, save_notes
150
- sonify_midi=False,
151
- save_midi_path=str(out_path) # Some versions allow direct path
152
- )
153
- # Cleanup: basic-pitch might name it slightly differently, ensure standard name
154
  expected = out_dir / f"{name}_basic_pitch.mid"
155
- if expected.exists():
156
- shutil.move(expected, out_path)
157
-
158
 
159
  # -----------------------------
160
- # Audio Processing (LUFS + One Shots)
161
  # -----------------------------
162
- def rms_dbfs(seg): return seg.dBFS
163
-
164
  def apply_loudness(seg: AudioSegment, mode: str, target: float = -14.0) -> AudioSegment:
165
  mode = (mode or "none").lower().strip()
166
  if mode == "none": return seg
167
  if mode == "peak": return seg.normalize()
168
-
169
  if mode == "rms":
170
  change = target - seg.dBFS
171
  return seg.apply_gain(change)
172
-
173
  if mode == "lufs":
174
- # Export to buffer for pyloudnorm
175
  samples = np.array(seg.get_array_of_samples())
176
- if seg.channels == 2:
177
- samples = samples.reshape((-1, 2))
178
-
179
- # Convert to float [-1, 1]
180
  samples_float = samples.astype(np.float64) / 32768.0
181
-
182
  meter = pyln.Meter(seg.frame_rate)
183
  loudness = meter.integrated_loudness(samples_float)
184
-
185
- if loudness == -float('inf'): return seg # Silence check
186
-
187
- # Calculate gain needed
188
- gain_db = target - loudness
189
- # Apply safely
190
- gain_db = max(min(gain_db, 20.0), -20.0) # Safety clamp
191
  return seg.apply_gain(gain_db)
192
-
193
  return seg
194
 
195
  def extract_one_shots(drum_stem_path, bpm, out_dir, loudness_mode, target_dbfs):
196
- """Extracts Kick/Snare hits from the drum stem."""
197
  y, sr = librosa.load(str(drum_stem_path), sr=None)
198
  onset_frames = librosa.onset.onset_detect(y=y, sr=sr, backtrack=True)
199
  onset_times = librosa.frames_to_time(onset_frames, sr=sr)
200
-
201
  audio = AudioSegment.from_wav(str(drum_stem_path))
202
  hits = []
203
-
204
- # Slice hits (take 250ms or until next hit)
205
  for i in range(len(onset_times)):
206
  start_ms = int(onset_times[i] * 1000)
207
- if i < len(onset_times) - 1:
208
- end_ms = int(onset_times[i+1] * 1000)
209
- dur = min(end_ms - start_ms, 450) # Cap at 450ms for one shots
210
- else:
211
- dur = 450
212
-
213
  hit = audio[start_ms : start_ms + dur]
214
-
215
- # Filter tiny noises
216
  if hit.rms > 100 and len(hit) > 30:
217
- hit = hit.fade_out(10) # Quick fade to prevent clicking
218
- hits.append(hit)
219
-
220
- # Save top 32 unique-ish hits
221
- # Sort by loudness to get main hits first
222
  hits.sort(key=lambda x: x.rms, reverse=True)
223
  hits = hits[:32]
224
-
225
  for i, hit in enumerate(hits):
226
  hit = apply_loudness(hit, mode=loudness_mode, target=target_dbfs)
227
  hit.export(out_dir / f"DrumShot_{i+1:02d}.wav", format="wav")
228
 
229
-
230
  # -----------------------------
231
  # Loop Engine
232
  # -----------------------------
@@ -236,18 +171,14 @@ def make_quantized_loops(stem_path, stem_name, bpm, key, bar_starts_ms, bar_leng
236
  if not stem_path.exists(): return []
237
  audio = AudioSegment.from_wav(str(stem_path))
238
  ms_per_bar = (240000.0 / bpm)
239
-
240
- # Seam Logic
241
  trim_win = 8
242
  extra_ms = (seam_ms if loop_seam else 0) + (trim_win * 2)
243
-
244
  grid = bar_starts_ms[::max(1, int(hop_bars))] if bar_starts_ms else []
245
  candidates = []
246
 
247
  for bar_len in bar_lengths:
248
  t_dur = int(ms_per_bar * bar_len)
249
  x_dur = t_dur + extra_ms
250
-
251
  for start_ms in grid:
252
  if start_ms + x_dur > len(audio): continue
253
  seg = audio[start_ms : start_ms + x_dur]
@@ -256,8 +187,6 @@ def make_quantized_loops(stem_path, stem_name, bpm, key, bar_starts_ms, bar_leng
256
 
257
  candidates.sort(key=lambda x: x[0], reverse=True)
258
  if top_k > 0: candidates = candidates[:int(top_k)]
259
-
260
- # De-dup
261
  selected = []
262
  used_bars = []
263
  for score, start, blen in candidates:
@@ -271,12 +200,8 @@ def make_quantized_loops(stem_path, stem_name, bpm, key, bar_starts_ms, bar_leng
271
  for i, (_, start, blen) in enumerate(selected, 1):
272
  t_dur = int(ms_per_bar * blen)
273
  x_dur = t_dur + extra_ms
274
-
275
  loop = audio[start : start + x_dur]
276
-
277
- # Process
278
  loop = loop[trim_win : -trim_win] if len(loop) > trim_win*2 else loop
279
-
280
  if loop_seam and len(loop) > seam_ms*2:
281
  head = loop[:seam_ms]
282
  tail = loop[-seam_ms:]
@@ -285,81 +210,63 @@ def make_quantized_loops(stem_path, stem_name, bpm, key, bar_starts_ms, bar_leng
285
  else:
286
  loop = loop[:t_dur]
287
  if fade_ms > 0: loop = loop.fade_in(fade_ms).fade_out(fade_ms)
288
-
289
- loop = loop[:t_dur] # Hard quantize
290
  loop = apply_loudness(loop, mode=loudness_mode, target=target_dbfs)
291
-
292
  fname = f"{bpm}BPM_{key}_{stem_name}_L{blen}bars_{i:02d}.wav"
293
  out_path = out_dir / fname
294
  loop.export(out_path, format="wav")
295
  exported.append(out_path)
296
-
297
  return exported
298
 
299
-
300
  # -----------------------------
301
  # Phase 1: Analyze
302
  # -----------------------------
303
  def analyze_and_separate(file_in, url_in, mode, manual_bpm):
304
  if TEMP_DIR.exists(): shutil.rmtree(TEMP_DIR, ignore_errors=True)
305
  TEMP_DIR.mkdir(parents=True, exist_ok=True)
306
-
307
  fpath = download_from_url(url_in) if url_in else file_in
308
  if not fpath: raise gr.Error("No Audio Source")
309
-
310
  fpath = safe_copy_to_temp(fpath)
311
  fpath = ensure_wav(fpath)
312
 
313
- # Key & BPM
314
  bpm = manual_bpm if manual_bpm else int(librosa.beat.beat_track(y=librosa.load(fpath, duration=60)[0])[0])
315
  key = detect_key(fpath)
316
- print(f"Detected: {bpm} BPM, Key: {key}")
317
 
318
- # Separate
319
  cmd = [sys.executable, "-m", "demucs", "-n", "htdemucs_6s" if mode=="6stem" else "htdemucs", "--out", str(TEMP_DIR), fpath]
320
  if mode == "2stem": cmd += ["--two-stems", "vocals"]
321
-
322
  run_demucs(cmd)
323
 
324
- # Map
325
  track_dir = next((TEMP_DIR / ("htdemucs_6s" if mode=="6stem" else "htdemucs")).iterdir())
326
-
327
- # Smart Defaults
328
- all_stems = [f.stem for f in track_dir.glob("*.wav")]
329
  stem_map = {
330
  "Drums": track_dir/"drums.wav", "Bass": track_dir/"bass.wav",
331
  "Vocals": track_dir/"vocals.wav", "Other": track_dir/"other.wav",
332
  "Piano": track_dir/"piano.wav", "Guitar": track_dir/"guitar.wav",
333
  "Instrumental": track_dir/"no_vocals.wav"
334
  }
 
 
335
 
336
- # Filter non-existent
337
- valid_stems = [k for k,v in stem_map.items() if v.exists()]
 
338
 
339
- # UI Updates
340
- loops_def = [s for s in valid_stems if s != "Vocals"]
 
 
341
 
342
- return (
343
- str(stem_map["Drums"]) if "Drums" in valid_stems else None,
344
- str(stem_map["Bass"]) if "Bass" in valid_stems else None,
345
- str(stem_map["Vocals"]) if "Vocals" in valid_stems else None,
346
- bpm, key, str(track_dir), mode,
347
- gr.CheckboxGroup(choices=valid_stems, value=valid_stems), # Exports
348
- gr.CheckboxGroup(choices=valid_stems, value=loops_def) # Loops
349
- )
350
-
351
 
352
  # -----------------------------
353
- # Phase 2: Package
354
  # -----------------------------
355
  def package_and_export(track_folder, bpm, key, stem_mode, art,
356
  ex_stems, loop_stems, do_midi, do_oneshots, do_vocal_chops,
357
  loops_per, bars, hop, topk, fadems, loopseam, seamms, mingap,
358
- loud_mode, loud_target, v_mode, v_grid, v_max, v_min, v_max_len, v_sil, v_sil_len):
359
 
360
  if not track_folder: raise gr.Error("Run Phase 1 First.")
361
 
362
- # Setup Dirs
363
  if OUTPUT_DIR.exists(): shutil.rmtree(OUTPUT_DIR, ignore_errors=True)
364
  for d in ["Stems", "Loops", "MIDI", "OneShots", "Vocal_Chops"]:
365
  (OUTPUT_DIR / d).mkdir(parents=True, exist_ok=True)
@@ -372,87 +279,92 @@ def package_and_export(track_folder, bpm, key, stem_mode, art,
372
  "Instrumental": t_dir/"no_vocals.wav"
373
  }
374
 
375
- # 1. Full Stems
376
  for s in ex_stems:
377
  if stems.get(s, Path("x")).exists():
378
  shutil.copy(stems[s], OUTPUT_DIR/"Stems"/f"{bpm}BPM_{key}_{s}.wav")
379
-
380
- # 2. MIDI (Melodic only)
381
  if do_midi and MIDI_AVAILABLE:
382
  for s in ["Bass", "Piano", "Guitar", "Other"]:
383
- if s in stems and stems[s].exists():
384
- extract_midi(stems[s], OUTPUT_DIR/"MIDI"/f"{bpm}BPM_{key}_{s}.mid")
385
-
386
- # 3. One Shots (Drums)
387
  if do_oneshots and stems["Drums"].exists():
388
  extract_one_shots(stems["Drums"], bpm, OUTPUT_DIR/"OneShots", loud_mode, loud_target)
389
-
390
- # 4. Loops
391
- grid_src = stems["Drums"] if stems["Drums"].exists() else next((stems[k] for k in stems if stems[k].exists()), None)
392
 
393
- # Get Grid
394
  y, sr = librosa.load(str(grid_src), sr=22050, duration=240)
395
  _, beats = librosa.beat.beat_track(y=y, sr=sr)
396
  beat_times = librosa.frames_to_time(beats, sr=sr)
397
- if len(beat_times) < 8:
398
- # Fallback to calculated grid
399
- bar_starts = [int(i * (240000/bpm)) for i in range(int((len(y)/sr)/(240/bpm)))]
400
- else:
401
- bar_starts = [int(t*1000) for t in beat_times[::4]]
402
-
403
  bar_ints = sorted([int(b) for b in bars])
404
 
405
  all_loops = {}
406
  for s in loop_stems:
407
- if s == "Vocals" and do_vocal_chops: continue # Handled separately
408
  if stems.get(s, Path("x")).exists():
409
  exported = make_quantized_loops(stems[s], s, bpm, key, bar_starts, bar_ints, hop, loops_per, topk,
410
  fadems, loopseam, seamms, mingap, loud_mode, loud_target, OUTPUT_DIR/"Loops")
411
  all_loops[s] = exported
412
 
413
- # 5. Vocal Chops (Simplified logic for brevity, uses same engine as before effectively)
414
  if do_vocal_chops and stems["Vocals"].exists():
415
- # Re-using quantization engine if "Loop" mode, or custom chop if needed.
416
- # For "Ultimate" script, we treat chops as loops but strictly processed.
417
  exported = make_quantized_loops(stems["Vocals"], "Vocals_Chop", bpm, key, bar_starts, [1, 2], 1, 30, 0,
418
  fadems, False, 0, 0, loud_mode, loud_target, OUTPUT_DIR/"Vocal_Chops")
419
  all_loops["Vocals"] = exported
420
 
421
- # 6. Video (Progress Bar)
422
  vid_path = None
423
  if art and any(all_loops.values()):
424
- # Find audio
425
  for k in ["Other", "Synths", "Piano", "Guitar", "Instrumental", "Bass", "Drums"]:
426
  if all_loops.get(k):
427
  a_path = all_loops[k][0]
428
  break
429
 
430
- print("Rendering Social Video...")
 
 
 
 
 
 
 
 
431
  clip = AudioFileClip(str(a_path))
432
- w, h = 1080, 1920
433
 
434
- # Background
435
- bg = ImageClip(art).resize(width=w)
436
- # Slight zoom effect
437
- bg = bg.resize(lambda t: 1 + 0.02*t).set_position("center").set_duration(clip.duration)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
 
439
  # Progress Bar
440
- bar_h = 20
441
- # ColorClip is usually safe, but let's use a solid color ImageClip to be safe with v1.0.3
442
- bar = ColorClip(size=(w, bar_h), color=(255,255,255)).set_opacity(0.8)
443
- # Animate width by masking or resizing. Resizing is easiest for MP 1.0.3
444
- # We start at width 0. We can't resize 0. So we assume full width and use a mask?
445
- # Easier: moving position.
446
- # Make a bar of full width, move it from x = -1080 to x = 0
447
- bar = bar.set_position(lambda t: (int(-w + w*(t/clip.duration)), h - 100))
448
  bar = bar.set_duration(clip.duration)
449
 
450
- final = CompositeVideoClip([bg, bar], size=(w,h))
451
  final.audio = clip
452
  vid_path = str(OUTPUT_DIR/"Promo.mp4")
453
  final.write_videofile(vid_path, fps=24, codec="libx264", audio_codec="aac", logger=None)
454
 
455
- # Zip
456
  z_path = "NightPulse_Ultimate.zip"
457
  with zipfile.ZipFile(z_path, "w") as zf:
458
  for r, _, fs in os.walk(OUTPUT_DIR):
@@ -460,58 +372,66 @@ def package_and_export(track_folder, bpm, key, stem_mode, art,
460
 
461
  return z_path, vid_path
462
 
463
-
464
  # -----------------------------
465
- # UI
466
  # -----------------------------
467
  with gr.Blocks(title="Night Pulse | Ultimate") as app:
468
  gr.Markdown("# 🎹 Night Pulse | Studio Ultimate")
469
 
 
470
  folder = gr.State()
471
  bpm_st = gr.State()
472
  key_st = gr.State()
473
  mode_st = gr.State()
474
 
475
  with gr.Row():
476
- with gr.Column():
477
- gr.Markdown("### 1. Source")
478
- mode = gr.Dropdown([("2 Stems", "2stem"), ("4 Stems", "4stem"), ("6 Stems", "6stem")], value="6stem", label="Model")
479
- mbpm = gr.Number(label="Manual BPM (Optional)")
480
-
481
  with gr.Tabs():
482
- with gr.Tab("Link"): url = gr.Textbox(label="URL")
483
- with gr.Tab("File"): file = gr.Audio(type="filepath", label="Upload")
484
 
485
- art = gr.Image(type="filepath", label="Cover Art (9:16)")
486
- btn1 = gr.Button("Phase 1: Analyze & Separate", variant="primary")
487
 
488
- with gr.Column():
489
- gr.Markdown("### 2. Preview")
490
- p1 = gr.Audio(label="Drums")
491
- p2 = gr.Audio(label="Bass")
492
- p3 = gr.Audio(label="Vocals")
 
 
 
 
 
 
493
  info = gr.Markdown("Waiting for analysis...")
 
 
 
 
 
 
 
 
 
 
494
 
495
  gr.Markdown("---")
496
 
497
  with gr.Row():
498
- with gr.Column():
499
- gr.Markdown("### 3. Selection")
500
- ex_stems = gr.CheckboxGroup(label="Export Full Stems")
501
- lp_stems = gr.CheckboxGroup(label="Generate Loops For")
502
-
503
- gr.Markdown("### 4. Extra Processing")
504
- do_midi = gr.Checkbox(label="Extract MIDI (Melody/Bass)", value=True)
505
- do_oneshots = gr.Checkbox(label="Extract Drum One-Shots", value=True)
506
- do_vox = gr.Checkbox(label="Vocal Chops", value=True)
507
-
508
- with gr.Column():
509
- gr.Markdown("### 5. Engine Settings")
510
- loops_per = gr.Slider(1, 40, 12, 1, label="Loops Count")
511
- bars = gr.CheckboxGroup(["1","2","4","8"], ["4","8"], label="Lengths")
512
- hop = gr.Slider(1, 8, 1, 1, label="Hop")
513
- topk = gr.Slider(0, 100, 30, 1, label="Top K")
514
 
 
 
 
515
  with gr.Accordion("Advanced Audio", open=False):
516
  l_mode = gr.Dropdown(["none", "peak", "rms", "lufs"], "lufs", label="Norm Mode")
517
  l_target = gr.Slider(-24, -5, -14, 1, label="Target Level")
@@ -519,25 +439,26 @@ with gr.Blocks(title="Night Pulse | Ultimate") as app:
519
  seam = gr.Checkbox(True, label="Loop Seam")
520
  seamms = gr.Slider(0, 100, 20, label="Seam ms")
521
  mingap = gr.Slider(0,16,4, label="De-Dup Gap")
 
522
 
523
- btn2 = gr.Button("Phase 2: Package Ultimate Pack", variant="primary")
524
 
525
- with gr.Row():
526
- z_out = gr.File(label="Zip Pack")
527
- v_out = gr.Video(label="Social Video")
 
 
528
 
529
  # Wire up
530
  def p1_wrap(f, u, m, b):
531
  d, ba, v, bpm, key, pth, md, c1, c2 = analyze_and_separate(f, u, m, b)
532
- return d, ba, v, f"**Detected:** {bpm} BPM | **Key:** {key}", bpm, key, pth, md, c1, c2
533
 
534
  btn1.click(p1_wrap, [file, url, mode, mbpm], [p1, p2, p3, info, bpm_st, key_st, folder, mode_st, ex_stems, lp_stems])
535
 
536
  btn2.click(package_and_export,
537
  [folder, bpm_st, key_st, mode_st, art, ex_stems, lp_stems, do_midi, do_oneshots, do_vox,
538
- loops_per, bars, hop, topk, fadems, seam, seamms, mingap, l_mode, l_target,
539
- # Vocal settings (dummy values for now to keep UI clean, can be expanded)
540
- gr.State("grid"), gr.State("1beat"), gr.State(64), gr.State(100), gr.State(1000), gr.State(-30), gr.State(100)],
541
  [z_out, v_out])
542
 
543
  if __name__ == "__main__":
 
16
  from datetime import datetime
17
  import pyloudnorm as pyln
18
 
19
+ # --- OPTIONAL: MIDI IMPORT ---
20
  try:
21
  from basic_pitch.inference import predict_and_save
22
  MIDI_AVAILABLE = True
 
24
  MIDI_AVAILABLE = False
25
  print("WARNING: 'basic-pitch' not installed. MIDI extraction will be disabled.")
26
 
27
+ # --- PATCH FOR PILLOW 10.0+ ---
28
  import PIL.Image
29
  if not hasattr(PIL.Image, 'ANTIALIAS'):
30
  PIL.Image.ANTIALIAS = PIL.Image.LANCZOS
 
31
 
32
+ # --- CONFIG ---
33
  OUTPUT_DIR = Path("nightpulse_output")
34
  TEMP_DIR = Path("temp_processing")
35
 
 
36
  # -----------------------------
37
  # Startup Checks
38
  # -----------------------------
 
44
 
45
  check_ffmpeg()
46
 
 
47
  # -----------------------------
48
+ # Key Detection
49
  # -----------------------------
50
  def detect_key(audio_path):
 
 
 
51
  try:
52
  y, sr = librosa.load(str(audio_path), sr=None, duration=60)
53
  chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
54
  chroma_vals = np.sum(chroma, axis=1)
 
 
55
  maj_profile = [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88]
56
  min_profile = [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17]
57
  pitches = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
 
58
  best_score = -1
59
  best_key = "Unknown"
 
60
  for i in range(12):
61
  p_maj = np.roll(maj_profile, i)
62
  p_min = np.roll(min_profile, i)
 
63
  score_maj = np.corrcoef(chroma_vals, p_maj)[0, 1]
64
  score_min = np.corrcoef(chroma_vals, p_min)[0, 1]
 
65
  if score_maj > best_score:
66
  best_score = score_maj
67
  best_key = f"{pitches[i]}maj"
68
  if score_min > best_score:
69
  best_score = score_min
70
  best_key = f"{pitches[i]}min"
 
71
  return best_key
72
  except Exception:
73
  return "Unknown"
74
 
 
75
  # -----------------------------
76
+ # Helpers
77
  # -----------------------------
78
  def download_from_url(url):
79
  if not url: return None
 
80
  if TEMP_DIR.exists(): shutil.rmtree(TEMP_DIR, ignore_errors=True)
81
  TEMP_DIR.mkdir(parents=True, exist_ok=True)
 
82
  ydl_opts = {
83
  "format": "bestaudio/best",
84
  "outtmpl": str(TEMP_DIR / "%(title)s.%(ext)s"),
 
90
  filename = ydl.prepare_filename(info)
91
  return str(Path(filename).with_suffix(".wav"))
92
 
 
 
 
 
93
  def safe_copy_to_temp(audio_file: str) -> str:
94
  src = Path(audio_file)
95
  TEMP_DIR.mkdir(parents=True, exist_ok=True)
 
107
  AudioSegment.from_file(str(p)).export(str(out), format="wav")
108
  return str(out)
109
 
 
110
  # -----------------------------
111
  # Demucs + MIDI
112
  # -----------------------------
113
  def run_demucs(cmd):
114
  p = subprocess.run(cmd, capture_output=True, text=True)
115
+ if p.returncode != 0: raise gr.Error(f"Demucs Error:\n{p.stderr[-2000:]}")
 
116
  return p.stdout
117
 
118
  def extract_midi(audio_path, out_path):
 
119
  if not MIDI_AVAILABLE: return
 
120
  out_dir = out_path.parent
121
  name = out_path.stem
122
+ predict_and_save([str(audio_path)], str(out_dir), True, False, False, sonify_midi=False, save_midi_path=str(out_path))
 
 
 
 
 
 
 
123
  expected = out_dir / f"{name}_basic_pitch.mid"
124
+ if expected.exists(): shutil.move(expected, out_path)
 
 
125
 
126
  # -----------------------------
127
+ # Audio Processing
128
  # -----------------------------
 
 
129
  def apply_loudness(seg: AudioSegment, mode: str, target: float = -14.0) -> AudioSegment:
130
  mode = (mode or "none").lower().strip()
131
  if mode == "none": return seg
132
  if mode == "peak": return seg.normalize()
 
133
  if mode == "rms":
134
  change = target - seg.dBFS
135
  return seg.apply_gain(change)
 
136
  if mode == "lufs":
 
137
  samples = np.array(seg.get_array_of_samples())
138
+ if seg.channels == 2: samples = samples.reshape((-1, 2))
 
 
 
139
  samples_float = samples.astype(np.float64) / 32768.0
 
140
  meter = pyln.Meter(seg.frame_rate)
141
  loudness = meter.integrated_loudness(samples_float)
142
+ if loudness == -float('inf'): return seg
143
+ gain_db = max(min(target - loudness, 20.0), -20.0)
 
 
 
 
 
144
  return seg.apply_gain(gain_db)
 
145
  return seg
146
 
147
  def extract_one_shots(drum_stem_path, bpm, out_dir, loudness_mode, target_dbfs):
 
148
  y, sr = librosa.load(str(drum_stem_path), sr=None)
149
  onset_frames = librosa.onset.onset_detect(y=y, sr=sr, backtrack=True)
150
  onset_times = librosa.frames_to_time(onset_frames, sr=sr)
 
151
  audio = AudioSegment.from_wav(str(drum_stem_path))
152
  hits = []
 
 
153
  for i in range(len(onset_times)):
154
  start_ms = int(onset_times[i] * 1000)
155
+ dur = min(int(onset_times[i+1] * 1000) - start_ms, 450) if i < len(onset_times) - 1 else 450
 
 
 
 
 
156
  hit = audio[start_ms : start_ms + dur]
 
 
157
  if hit.rms > 100 and len(hit) > 30:
158
+ hits.append(hit.fade_out(10))
 
 
 
 
159
  hits.sort(key=lambda x: x.rms, reverse=True)
160
  hits = hits[:32]
 
161
  for i, hit in enumerate(hits):
162
  hit = apply_loudness(hit, mode=loudness_mode, target=target_dbfs)
163
  hit.export(out_dir / f"DrumShot_{i+1:02d}.wav", format="wav")
164
 
 
165
  # -----------------------------
166
  # Loop Engine
167
  # -----------------------------
 
171
  if not stem_path.exists(): return []
172
  audio = AudioSegment.from_wav(str(stem_path))
173
  ms_per_bar = (240000.0 / bpm)
 
 
174
  trim_win = 8
175
  extra_ms = (seam_ms if loop_seam else 0) + (trim_win * 2)
 
176
  grid = bar_starts_ms[::max(1, int(hop_bars))] if bar_starts_ms else []
177
  candidates = []
178
 
179
  for bar_len in bar_lengths:
180
  t_dur = int(ms_per_bar * bar_len)
181
  x_dur = t_dur + extra_ms
 
182
  for start_ms in grid:
183
  if start_ms + x_dur > len(audio): continue
184
  seg = audio[start_ms : start_ms + x_dur]
 
187
 
188
  candidates.sort(key=lambda x: x[0], reverse=True)
189
  if top_k > 0: candidates = candidates[:int(top_k)]
 
 
190
  selected = []
191
  used_bars = []
192
  for score, start, blen in candidates:
 
200
  for i, (_, start, blen) in enumerate(selected, 1):
201
  t_dur = int(ms_per_bar * blen)
202
  x_dur = t_dur + extra_ms
 
203
  loop = audio[start : start + x_dur]
 
 
204
  loop = loop[trim_win : -trim_win] if len(loop) > trim_win*2 else loop
 
205
  if loop_seam and len(loop) > seam_ms*2:
206
  head = loop[:seam_ms]
207
  tail = loop[-seam_ms:]
 
210
  else:
211
  loop = loop[:t_dur]
212
  if fade_ms > 0: loop = loop.fade_in(fade_ms).fade_out(fade_ms)
213
+ loop = loop[:t_dur]
 
214
  loop = apply_loudness(loop, mode=loudness_mode, target=target_dbfs)
 
215
  fname = f"{bpm}BPM_{key}_{stem_name}_L{blen}bars_{i:02d}.wav"
216
  out_path = out_dir / fname
217
  loop.export(out_path, format="wav")
218
  exported.append(out_path)
 
219
  return exported
220
 
 
221
  # -----------------------------
222
  # Phase 1: Analyze
223
  # -----------------------------
224
  def analyze_and_separate(file_in, url_in, mode, manual_bpm):
225
  if TEMP_DIR.exists(): shutil.rmtree(TEMP_DIR, ignore_errors=True)
226
  TEMP_DIR.mkdir(parents=True, exist_ok=True)
 
227
  fpath = download_from_url(url_in) if url_in else file_in
228
  if not fpath: raise gr.Error("No Audio Source")
 
229
  fpath = safe_copy_to_temp(fpath)
230
  fpath = ensure_wav(fpath)
231
 
 
232
  bpm = manual_bpm if manual_bpm else int(librosa.beat.beat_track(y=librosa.load(fpath, duration=60)[0])[0])
233
  key = detect_key(fpath)
 
234
 
 
235
  cmd = [sys.executable, "-m", "demucs", "-n", "htdemucs_6s" if mode=="6stem" else "htdemucs", "--out", str(TEMP_DIR), fpath]
236
  if mode == "2stem": cmd += ["--two-stems", "vocals"]
 
237
  run_demucs(cmd)
238
 
 
239
  track_dir = next((TEMP_DIR / ("htdemucs_6s" if mode=="6stem" else "htdemucs")).iterdir())
 
 
 
240
  stem_map = {
241
  "Drums": track_dir/"drums.wav", "Bass": track_dir/"bass.wav",
242
  "Vocals": track_dir/"vocals.wav", "Other": track_dir/"other.wav",
243
  "Piano": track_dir/"piano.wav", "Guitar": track_dir/"guitar.wav",
244
  "Instrumental": track_dir/"no_vocals.wav"
245
  }
246
+ valid = [k for k,v in stem_map.items() if v.exists()]
247
+ loops_def = [s for s in valid if s != "Vocals"]
248
 
249
+ # UI Updates for Checkboxes
250
+ cb_export = gr.CheckboxGroup(choices=valid, value=valid)
251
+ cb_loops = gr.CheckboxGroup(choices=valid, value=loops_def)
252
 
253
+ # Previews
254
+ p_d = str(stem_map["Drums"]) if "Drums" in valid else None
255
+ p_b = str(stem_map["Bass"]) if "Bass" in valid else None
256
+ p_v = str(stem_map["Vocals"]) if "Vocals" in valid else None
257
 
258
+ return (p_d, p_b, p_v, f"**Detected:** {bpm} BPM | **Key:** {key}", bpm, key, str(track_dir), mode, cb_export, cb_loops)
 
 
 
 
 
 
 
 
259
 
260
  # -----------------------------
261
+ # Phase 2: Package (With Smart Video Resize)
262
  # -----------------------------
263
  def package_and_export(track_folder, bpm, key, stem_mode, art,
264
  ex_stems, loop_stems, do_midi, do_oneshots, do_vocal_chops,
265
  loops_per, bars, hop, topk, fadems, loopseam, seamms, mingap,
266
+ loud_mode, loud_target, vid_fmt):
267
 
268
  if not track_folder: raise gr.Error("Run Phase 1 First.")
269
 
 
270
  if OUTPUT_DIR.exists(): shutil.rmtree(OUTPUT_DIR, ignore_errors=True)
271
  for d in ["Stems", "Loops", "MIDI", "OneShots", "Vocal_Chops"]:
272
  (OUTPUT_DIR / d).mkdir(parents=True, exist_ok=True)
 
279
  "Instrumental": t_dir/"no_vocals.wav"
280
  }
281
 
282
+ # Exports (Stems, MIDI, Shots, Loops)
283
  for s in ex_stems:
284
  if stems.get(s, Path("x")).exists():
285
  shutil.copy(stems[s], OUTPUT_DIR/"Stems"/f"{bpm}BPM_{key}_{s}.wav")
 
 
286
  if do_midi and MIDI_AVAILABLE:
287
  for s in ["Bass", "Piano", "Guitar", "Other"]:
288
+ if stems.get(s, Path("x")).exists(): extract_midi(stems[s], OUTPUT_DIR/"MIDI"/f"{bpm}BPM_{key}_{s}.mid")
 
 
 
289
  if do_oneshots and stems["Drums"].exists():
290
  extract_one_shots(stems["Drums"], bpm, OUTPUT_DIR/"OneShots", loud_mode, loud_target)
 
 
 
291
 
292
+ grid_src = stems["Drums"] if stems["Drums"].exists() else next((stems[k] for k in stems if stems[k].exists()), None)
293
  y, sr = librosa.load(str(grid_src), sr=22050, duration=240)
294
  _, beats = librosa.beat.beat_track(y=y, sr=sr)
295
  beat_times = librosa.frames_to_time(beats, sr=sr)
296
+ bar_starts = [int(t*1000) for t in beat_times[::4]] if len(beat_times) >= 8 else [int(i * (240000/bpm)) for i in range(int((len(y)/sr)/(240/bpm)))]
 
 
 
 
 
297
  bar_ints = sorted([int(b) for b in bars])
298
 
299
  all_loops = {}
300
  for s in loop_stems:
301
+ if s == "Vocals" and do_vocal_chops: continue
302
  if stems.get(s, Path("x")).exists():
303
  exported = make_quantized_loops(stems[s], s, bpm, key, bar_starts, bar_ints, hop, loops_per, topk,
304
  fadems, loopseam, seamms, mingap, loud_mode, loud_target, OUTPUT_DIR/"Loops")
305
  all_loops[s] = exported
306
 
 
307
  if do_vocal_chops and stems["Vocals"].exists():
 
 
308
  exported = make_quantized_loops(stems["Vocals"], "Vocals_Chop", bpm, key, bar_starts, [1, 2], 1, 30, 0,
309
  fadems, False, 0, 0, loud_mode, loud_target, OUTPUT_DIR/"Vocal_Chops")
310
  all_loops["Vocals"] = exported
311
 
312
+ # --- SMART VIDEO ENGINE ---
313
  vid_path = None
314
  if art and any(all_loops.values()):
 
315
  for k in ["Other", "Synths", "Piano", "Guitar", "Instrumental", "Bass", "Drums"]:
316
  if all_loops.get(k):
317
  a_path = all_loops[k][0]
318
  break
319
 
320
+ print(f"Rendering Video ({vid_fmt})...")
321
+ # Define Resolution
322
+ res_map = {
323
+ "9:16 (TikTok/Reels)": (1080, 1920),
324
+ "16:9 (YouTube)": (1920, 1080),
325
+ "1:1 (Square)": (1080, 1080)
326
+ }
327
+ w, h = res_map.get(vid_fmt, (1080, 1920))
328
+
329
  clip = AudioFileClip(str(a_path))
 
330
 
331
+ # Load & Smart Crop
332
+ bg_clip = ImageClip(art)
333
+ img_w, img_h = bg_clip.size
334
+
335
+ # Calculate aspect ratios
336
+ target_aspect = w / h
337
+ img_aspect = img_w / img_h
338
+
339
+ if img_aspect > target_aspect:
340
+ # Image is wider than target: resize by height, crop width
341
+ new_h = h
342
+ new_w = int(img_w * (h / img_h))
343
+ bg_clip = bg_clip.resize(height=h)
344
+ # Center crop
345
+ crop_x = (new_w - w) // 2
346
+ bg_clip = bg_clip.crop(x1=crop_x, width=w)
347
+ else:
348
+ # Image is taller/narrower: resize by width, crop height
349
+ new_w = w
350
+ new_h = int(img_h * (w / img_w))
351
+ bg_clip = bg_clip.resize(width=w)
352
+ crop_y = (new_h - h) // 2
353
+ bg_clip = bg_clip.crop(y1=crop_y, height=h)
354
+
355
+ # Add zoom effect
356
+ bg_clip = bg_clip.resize(lambda t: 1 + 0.02*t).set_position("center").set_duration(clip.duration)
357
 
358
  # Progress Bar
359
+ bar = ColorClip(size=(w, 20), color=(255,255,255)).set_opacity(0.8)
360
+ bar = bar.set_position(lambda t: (int(-w + w*(t/clip.duration)), h - 50))
 
 
 
 
 
 
361
  bar = bar.set_duration(clip.duration)
362
 
363
+ final = CompositeVideoClip([bg_clip, bar], size=(w,h))
364
  final.audio = clip
365
  vid_path = str(OUTPUT_DIR/"Promo.mp4")
366
  final.write_videofile(vid_path, fps=24, codec="libx264", audio_codec="aac", logger=None)
367
 
 
368
  z_path = "NightPulse_Ultimate.zip"
369
  with zipfile.ZipFile(z_path, "w") as zf:
370
  for r, _, fs in os.walk(OUTPUT_DIR):
 
372
 
373
  return z_path, vid_path
374
 
 
375
  # -----------------------------
376
+ # UI Layout
377
  # -----------------------------
378
  with gr.Blocks(title="Night Pulse | Ultimate") as app:
379
  gr.Markdown("# 🎹 Night Pulse | Studio Ultimate")
380
 
381
+ # Hidden State
382
  folder = gr.State()
383
  bpm_st = gr.State()
384
  key_st = gr.State()
385
  mode_st = gr.State()
386
 
387
  with gr.Row():
388
+ # --- COL 1: CONFIGURATION ---
389
+ with gr.Column(scale=1):
390
+ gr.Markdown("### 1. Setup & Source")
 
 
391
  with gr.Tabs():
392
+ with gr.Tab("Link"): url = gr.Textbox(label="YouTube/SC URL")
393
+ with gr.Tab("File"): file = gr.Audio(type="filepath", label="Upload File")
394
 
395
+ mode = gr.Dropdown([("2 Stems (Vox+Inst)", "2stem"), ("4 Stems (Basic)", "4stem"), ("6 Stems (Full)", "6stem")], value="6stem", label="Separation Model")
396
+ mbpm = gr.Number(label="Manual BPM (Optional)")
397
 
398
+ gr.Markdown("#### Extraction Targets")
399
+ with gr.Row():
400
+ do_midi = gr.Checkbox(label="MIDI", value=True)
401
+ do_oneshots = gr.Checkbox(label="Drum Shots", value=True)
402
+ do_vox = gr.Checkbox(label="Vocal Chops", value=True)
403
+
404
+ btn1 = gr.Button("🚀 Phase 1: Analyze & Separate", variant="primary", scale=2)
405
+
406
+ # --- COL 2: REFINEMENT (Dynamic) ---
407
+ with gr.Column(scale=1):
408
+ gr.Markdown("### 2. Select & Refine")
409
  info = gr.Markdown("Waiting for analysis...")
410
+
411
+ # Dynamic checkboxes
412
+ ex_stems = gr.CheckboxGroup(label="Export Full Stems")
413
+ lp_stems = gr.CheckboxGroup(label="Generate Loops For")
414
+
415
+ gr.Markdown("#### Preview")
416
+ with gr.Row():
417
+ p1 = gr.Audio(label="Drums", show_label=True)
418
+ p2 = gr.Audio(label="Bass", show_label=True)
419
+ p3 = gr.Audio(label="Vocals", show_label=True)
420
 
421
  gr.Markdown("---")
422
 
423
  with gr.Row():
424
+ # --- COL 3: EXPORT SETTINGS ---
425
+ with gr.Column(scale=1):
426
+ gr.Markdown("### 3. Loop Engine & Video")
427
+ with gr.Row():
428
+ loops_per = gr.Slider(1, 40, 12, 1, label="Loops Count")
429
+ hop = gr.Slider(1, 8, 1, 1, label="Hop (Bars)")
430
+ bars = gr.CheckboxGroup(["1","2","4","8"], ["4","8"], label="Loop Lengths")
 
 
 
 
 
 
 
 
 
431
 
432
+ art = gr.Image(type="filepath", label="Cover Art (Auto-Resize)")
433
+ vid_fmt = gr.Dropdown(["9:16 (TikTok/Reels)", "16:9 (YouTube)", "1:1 (Square)"], value="9:16 (TikTok/Reels)", label="Video Format")
434
+
435
  with gr.Accordion("Advanced Audio", open=False):
436
  l_mode = gr.Dropdown(["none", "peak", "rms", "lufs"], "lufs", label="Norm Mode")
437
  l_target = gr.Slider(-24, -5, -14, 1, label="Target Level")
 
439
  seam = gr.Checkbox(True, label="Loop Seam")
440
  seamms = gr.Slider(0, 100, 20, label="Seam ms")
441
  mingap = gr.Slider(0,16,4, label="De-Dup Gap")
442
+ topk = gr.Slider(0, 100, 30, 1, label="Top K")
443
 
444
+ btn2 = gr.Button("📦 Phase 2: Package Ultimate Pack", variant="primary")
445
 
446
+ # --- COL 4: OUTPUT ---
447
+ with gr.Column(scale=1):
448
+ gr.Markdown("### 4. Final Downloads")
449
+ z_out = gr.File(label="Complete Pack (Zip)")
450
+ v_out = gr.Video(label="Social Media Promo")
451
 
452
  # Wire up
453
  def p1_wrap(f, u, m, b):
454
  d, ba, v, bpm, key, pth, md, c1, c2 = analyze_and_separate(f, u, m, b)
455
+ return d, ba, v, f"### 🎵 Detected: {bpm} BPM | Key: {key}", bpm, key, pth, md, c1, c2
456
 
457
  btn1.click(p1_wrap, [file, url, mode, mbpm], [p1, p2, p3, info, bpm_st, key_st, folder, mode_st, ex_stems, lp_stems])
458
 
459
  btn2.click(package_and_export,
460
  [folder, bpm_st, key_st, mode_st, art, ex_stems, lp_stems, do_midi, do_oneshots, do_vox,
461
+ loops_per, bars, hop, topk, fadems, seam, seamms, mingap, l_mode, l_target, vid_fmt],
 
 
462
  [z_out, v_out])
463
 
464
  if __name__ == "__main__":