SaltProphet commited on
Commit
d7e85fe
·
verified ·
1 Parent(s): de3830f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +255 -161
app.py CHANGED
@@ -6,14 +6,11 @@ import librosa
6
  import numpy as np
7
  import soundfile as sf
8
  from pydub import AudioSegment
9
- from pydub.silence import split_on_silence
10
  from moviepy.editor import AudioFileClip, ImageClip, CompositeVideoClip, ColorClip
11
  import subprocess
12
  from pathlib import Path
13
  import sys
14
  import yt_dlp
15
- import json
16
- from datetime import datetime
17
  import pyloudnorm as pyln
18
 
19
  # --- OPTIONAL: MIDI IMPORT ---
@@ -29,14 +26,17 @@ 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
- # -----------------------------
 
 
39
  def check_ffmpeg():
 
40
  if shutil.which("ffmpeg") is None:
41
  print("CRITICAL WARNING: FFmpeg not found in system PATH.")
42
  return False
@@ -44,24 +44,86 @@ def check_ffmpeg():
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"
@@ -72,107 +134,124 @@ def detect_key(audio_path):
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"),
85
- "postprocessors": [{"key": "FFmpegExtractAudio", "preferredcodec": "wav", "preferredquality": "192"}],
86
- "quiet": True, "no_warnings": True,
87
- }
88
- with yt_dlp.YoutubeDL(ydl_opts) as ydl:
89
- info = ydl.extract_info(url, download=True)
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)
96
- safe_stem = "".join(c if c.isalnum() or c in "._-" else "_" for c in src.stem)
97
- dst = TEMP_DIR / f"{safe_stem}{src.suffix.lower()}"
98
- try: shutil.copy(src, dst)
99
- except Exception: return str(src)
100
- return str(dst)
101
-
102
- def ensure_wav(input_path: str) -> str:
103
- p = Path(input_path)
104
- if p.suffix.lower() == ".wav": return str(p)
105
- TEMP_DIR.mkdir(parents=True, exist_ok=True)
106
- out = TEMP_DIR / f"{p.stem}.wav"
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
- # -----------------------------
168
- def make_quantized_loops(stem_path, stem_name, bpm, key, bar_starts_ms, bar_lengths,
169
- hop_bars, loops_per, top_k, fade_ms, loop_seam, seam_ms,
170
- min_bar_gap, loudness_mode, target_dbfs, out_dir):
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
 
@@ -187,8 +266,10 @@ def make_quantized_loops(stem_path, stem_name, bpm, key, bar_starts_ms, bar_leng
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:
193
  b_idx = int(np.argmin([abs(start - b) for b in bar_starts_ms]))
194
  if any(abs(b_idx - u) < min_bar_gap for u in used_bars): continue
@@ -197,12 +278,16 @@ def make_quantized_loops(stem_path, stem_name, bpm, key, bar_starts_ms, bar_leng
197
  if len(selected) >= loops_per: break
198
 
199
  exported = []
 
 
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:]
208
  body = loop[seam_ms:-seam_ms]
@@ -210,30 +295,43 @@ def make_quantized_loops(stem_path, stem_name, bpm, key, bar_starts_ms, bar_leng
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())
@@ -243,28 +341,27 @@ def analyze_and_separate(file_in, url_in, mode, manual_bpm):
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)
@@ -279,13 +376,15 @@ def package_and_export(track_folder, bpm, key, stem_mode, art,
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.get("Drums", Path("x")).exists():
290
  extract_one_shots(stems["Drums"], bpm, OUTPUT_DIR/"OneShots", loud_mode, loud_target)
291
 
@@ -296,23 +395,33 @@ def package_and_export(track_folder, bpm, key, stem_mode, art,
296
  y, sr = librosa.load(str(grid_src), sr=22050, duration=240)
297
  _, beats = librosa.beat.beat_track(y=y, sr=sr)
298
  beat_times = librosa.frames_to_time(beats, sr=sr)
299
- 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)))]
 
 
 
 
 
 
 
300
  bar_ints = sorted([int(b) for b in bars])
301
 
302
  all_loops = {}
303
  for s in loop_stems:
304
  if s == "Vocals" and do_vocal_chops: continue
305
  if stems.get(s, Path("x")).exists():
306
- exported = make_quantized_loops(stems[s], s, bpm, key, bar_starts, bar_ints, hop, loops_per, topk,
307
- fadems, loopseam, seamms, mingap, loud_mode, loud_target, OUTPUT_DIR/"Loops")
 
 
308
  all_loops[s] = exported
309
 
310
  if do_vocal_chops and stems.get("Vocals", Path("x")).exists():
311
- exported = make_quantized_loops(stems["Vocals"], "Vocals_Chop", bpm, key, bar_starts, [1, 2], 1, 30, 0,
312
- fadems, False, 0, 0, loud_mode, loud_target, OUTPUT_DIR/"Vocal_Chops")
 
 
313
  all_loops["Vocals"] = exported
314
 
315
- # --- SMART VIDEO ENGINE ---
316
  vid_path = None
317
  if art and any(all_loops.values()):
318
  for k in ["Other", "Synths", "Piano", "Guitar", "Instrumental", "Bass", "Drums"]:
@@ -321,15 +430,10 @@ def package_and_export(track_folder, bpm, key, stem_mode, art,
321
  break
322
 
323
  print(f"Rendering Video ({vid_fmt})...")
324
- res_map = {
325
- "9:16 (TikTok/Reels)": (1080, 1920),
326
- "16:9 (YouTube)": (1920, 1080),
327
- "1:1 (Square)": (1080, 1080)
328
- }
329
  w, h = res_map.get(vid_fmt, (1080, 1920))
330
 
331
  clip = AudioFileClip(str(a_path))
332
-
333
  bg_clip = ImageClip(art)
334
  img_w, img_h = bg_clip.size
335
 
@@ -338,123 +442,113 @@ def package_and_export(track_folder, bpm, key, stem_mode, art,
338
 
339
  if img_aspect > target_aspect:
340
  bg_clip = bg_clip.resize(height=h)
341
- new_w = bg_clip.w
342
- crop_x = (new_w - w) // 2
343
  bg_clip = bg_clip.crop(x1=crop_x, width=w)
344
  else:
345
  bg_clip = bg_clip.resize(width=w)
346
- new_h = bg_clip.h
347
- crop_y = (new_h - h) // 2
348
  bg_clip = bg_clip.crop(y1=crop_y, height=h)
349
 
350
  bg_clip = bg_clip.resize(lambda t: 1 + 0.02*t).set_position("center").set_duration(clip.duration)
351
-
352
- bar_h = 20
353
- bar = ColorClip(size=(w, bar_h), color=(255,255,255)).set_opacity(0.8)
354
  bar = bar.set_position(lambda t: (int(-w + w*(t/clip.duration)), h - 50))
355
  bar = bar.set_duration(clip.duration)
356
 
357
  final = CompositeVideoClip([bg_clip, bar], size=(w,h))
358
  final.audio = clip
359
- vid_path = str(OUTPUT_DIR/"Promo.mp4")
360
  final.write_videofile(vid_path, fps=24, codec="libx264", audio_codec="aac", logger=None)
361
 
362
  z_path = "NightPulse_Ultimate.zip"
363
  with zipfile.ZipFile(z_path, "w") as zf:
364
  for r, _, fs in os.walk(OUTPUT_DIR):
365
- for f in fs: zf.write(Path(r)/f, Path(r).relative_to(OUTPUT_DIR)/f)
 
366
 
367
  return z_path, vid_path
368
 
369
- # -----------------------------
370
- # UI Layout
371
- # -----------------------------
 
 
372
  with gr.Blocks(title="Night Pulse | Ultimate") as app:
373
  gr.Markdown("# 🎹 Night Pulse | Studio Ultimate")
374
 
375
- # Hidden State
376
  folder = gr.State()
377
  bpm_st = gr.State()
378
  key_st = gr.State()
379
  mode_st = gr.State()
380
 
381
  with gr.Row():
382
- # --- COL 1: CONFIGURATION ---
383
  with gr.Column(scale=1):
384
- gr.Markdown("### 1. Setup & Source")
385
  with gr.Tabs():
386
- with gr.Tab("Link"): url = gr.Textbox(label="YouTube/SC URL")
387
- with gr.Tab("File"): file = gr.Audio(type="filepath", label="Upload File")
 
 
388
 
389
- mode = gr.Dropdown([("2 Stems (Vox+Inst)", "2stem"), ("4 Stems (Basic)", "4stem"), ("6 Stems (Full)", "6stem")], value="6stem", label="Separation Model")
390
  mbpm = gr.Number(label="Manual BPM (Optional)")
391
 
392
- gr.Markdown("#### Extraction Targets")
393
  with gr.Row():
394
  do_midi = gr.Checkbox(label="MIDI", value=True)
395
  do_oneshots = gr.Checkbox(label="Drum Shots", value=True)
396
  do_vox = gr.Checkbox(label="Vocal Chops", value=True)
397
 
398
- btn1 = gr.Button("🚀 Phase 1: Analyze & Separate", variant="primary", scale=2)
399
 
400
- # --- COL 2: REFINEMENT (Dynamic) ---
401
  with gr.Column(scale=1):
402
- gr.Markdown("### 2. Select & Refine")
403
- info = gr.Markdown("Waiting for analysis...")
404
-
405
- # Dynamic checkboxes
406
- ex_stems = gr.CheckboxGroup(label="Export Full Stems")
407
- lp_stems = gr.CheckboxGroup(label="Generate Loops For")
408
 
409
- gr.Markdown("#### Preview")
410
  with gr.Row():
411
- p1 = gr.Audio(label="Drums", show_label=True)
412
- p2 = gr.Audio(label="Bass", show_label=True)
413
- p3 = gr.Audio(label="Vocals", show_label=True)
414
 
415
  gr.Markdown("---")
416
 
417
  with gr.Row():
418
- # --- COL 3: EXPORT SETTINGS ---
419
  with gr.Column(scale=1):
420
- gr.Markdown("### 3. Loop Engine & Video")
421
  with gr.Row():
422
  loops_per = gr.Slider(1, 40, 12, 1, label="Loops Count")
423
  hop = gr.Slider(1, 8, 1, 1, label="Hop (Bars)")
424
- bars = gr.CheckboxGroup(["1","2","4","8"], ["4","8"], label="Loop Lengths")
425
 
426
- art = gr.Image(type="filepath", label="Cover Art (Auto-Resize)")
427
  vid_fmt = gr.Dropdown(["9:16 (TikTok/Reels)", "16:9 (YouTube)", "1:1 (Square)"], value="9:16 (TikTok/Reels)", label="Video Format")
428
 
429
- with gr.Accordion("Advanced Audio", open=False):
430
  l_mode = gr.Dropdown(["none", "peak", "rms", "lufs"], "lufs", label="Norm Mode")
431
- l_target = gr.Slider(-24, -5, -14, 1, label="Target Level")
432
  fadems = gr.Slider(0, 50, 10, label="Fade ms")
433
- seam = gr.Checkbox(True, label="Loop Seam")
434
  seamms = gr.Slider(0, 100, 20, label="Seam ms")
435
  mingap = gr.Slider(0,16,4, label="De-Dup Gap")
436
  topk = gr.Slider(0, 100, 30, 1, label="Top K")
437
 
438
- btn2 = gr.Button("📦 Phase 2: Package Ultimate Pack", variant="primary")
439
 
440
- # --- COL 4: OUTPUT ---
441
  with gr.Column(scale=1):
442
- gr.Markdown("### 4. Final Downloads")
443
- z_out = gr.File(label="Complete Pack (Zip)")
444
- v_out = gr.Video(label="Social Media Promo")
445
 
446
- # Wire up
447
  def p1_wrap(f, u, m, b):
448
- d, ba, v, bpm, key, pth, md, c1, c2 = analyze_and_separate(f, u, m, b)
449
- return d, ba, v, f"### 🎵 Detected: {bpm} BPM | Key: {key}", bpm, key, pth, md, c1, c2
450
 
451
  btn1.click(p1_wrap, [file, url, mode, mbpm], [p1, p2, p3, info, bpm_st, key_st, folder, mode_st, ex_stems, lp_stems])
452
 
453
- # Corrected variable name: 'seam' instead of 'loopseam'
454
  btn2.click(package_and_export,
455
  [folder, bpm_st, key_st, mode_st, art, ex_stems, lp_stems, do_midi, do_oneshots, do_vox,
456
  loops_per, bars, hop, topk, fadems, seam, seamms, mingap, l_mode, l_target, vid_fmt],
457
  [z_out, v_out])
458
 
459
  if __name__ == "__main__":
460
- app.launch(ssr_mode=False)
 
6
  import numpy as np
7
  import soundfile as sf
8
  from pydub import AudioSegment
 
9
  from moviepy.editor import AudioFileClip, ImageClip, CompositeVideoClip, ColorClip
10
  import subprocess
11
  from pathlib import Path
12
  import sys
13
  import yt_dlp
 
 
14
  import pyloudnorm as pyln
15
 
16
  # --- OPTIONAL: MIDI IMPORT ---
 
26
  if not hasattr(PIL.Image, 'ANTIALIAS'):
27
  PIL.Image.ANTIALIAS = PIL.Image.LANCZOS
28
 
29
+ # --- CONFIGURATION ---
30
  OUTPUT_DIR = Path("nightpulse_output")
31
  TEMP_DIR = Path("temp_processing")
32
 
33
+
34
+ # ==========================================
35
+ # 1. SYSTEM UTILITIES
36
+ # ==========================================
37
+
38
  def check_ffmpeg():
39
+ """Checks if FFmpeg is installed and accessible."""
40
  if shutil.which("ffmpeg") is None:
41
  print("CRITICAL WARNING: FFmpeg not found in system PATH.")
42
  return False
 
44
 
45
  check_ffmpeg()
46
 
47
+
48
+ # ==========================================
49
+ # 2. HELPER FUNCTIONS
50
+ # ==========================================
51
+
52
+ def download_from_url(url):
53
+ """Downloads audio from YouTube/SoundCloud using yt-dlp."""
54
+ if not url:
55
+ return None
56
+
57
+ if TEMP_DIR.exists():
58
+ shutil.rmtree(TEMP_DIR, ignore_errors=True)
59
+ TEMP_DIR.mkdir(parents=True, exist_ok=True)
60
+
61
+ ydl_opts = {
62
+ "format": "bestaudio/best",
63
+ "outtmpl": str(TEMP_DIR / "%(title)s.%(ext)s"),
64
+ "postprocessors": [{"key": "FFmpegExtractAudio", "preferredcodec": "wav", "preferredquality": "192"}],
65
+ "quiet": True,
66
+ "no_warnings": True,
67
+ }
68
+
69
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
70
+ info = ydl.extract_info(url, download=True)
71
+ filename = ydl.prepare_filename(info)
72
+ final_path = Path(filename).with_suffix(".wav")
73
+ return str(final_path)
74
+
75
+
76
+ def safe_copy_to_temp(audio_file: str) -> str:
77
+ """Copies uploaded file to temp with a safe filename."""
78
+ src = Path(audio_file)
79
+ TEMP_DIR.mkdir(parents=True, exist_ok=True)
80
+ safe_stem = "".join(c if c.isalnum() or c in "._-" else "_" for c in src.stem)
81
+ dst = TEMP_DIR / f"{safe_stem}{src.suffix.lower()}"
82
+ try:
83
+ shutil.copy(src, dst)
84
+ except Exception:
85
+ return str(src)
86
+ return str(dst)
87
+
88
+
89
+ def ensure_wav(input_path: str) -> str:
90
+ """Converts MP3/M4A to WAV for Demucs compatibility."""
91
+ p = Path(input_path)
92
+ if p.suffix.lower() == ".wav":
93
+ return str(p)
94
+
95
+ TEMP_DIR.mkdir(parents=True, exist_ok=True)
96
+ out = TEMP_DIR / f"{p.stem}.wav"
97
+
98
+ audio = AudioSegment.from_file(str(p))
99
+ audio.export(str(out), format="wav")
100
+ return str(out)
101
+
102
+
103
+ # ==========================================
104
+ # 3. AI ENGINES (Demucs, MIDI, Key)
105
+ # ==========================================
106
+
107
  def detect_key(audio_path):
108
+ """Estimates musical key using Librosa Chroma."""
109
  try:
110
  y, sr = librosa.load(str(audio_path), sr=None, duration=60)
111
  chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
112
  chroma_vals = np.sum(chroma, axis=1)
113
+
114
  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]
115
  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]
116
  pitches = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
117
+
118
  best_score = -1
119
  best_key = "Unknown"
120
+
121
  for i in range(12):
122
  p_maj = np.roll(maj_profile, i)
123
  p_min = np.roll(min_profile, i)
124
  score_maj = np.corrcoef(chroma_vals, p_maj)[0, 1]
125
  score_min = np.corrcoef(chroma_vals, p_min)[0, 1]
126
+
127
  if score_maj > best_score:
128
  best_score = score_maj
129
  best_key = f"{pitches[i]}maj"
 
134
  except Exception:
135
  return "Unknown"
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
 
 
 
138
  def run_demucs(cmd):
139
+ """Runs the Demucs separation command."""
140
  p = subprocess.run(cmd, capture_output=True, text=True)
141
+ if p.returncode != 0:
142
+ raise gr.Error(f"Demucs Error:\n{p.stderr[-2000:]}")
143
  return p.stdout
144
 
145
+
146
  def extract_midi(audio_path, out_path):
147
+ """Converts audio to MIDI using Spotify Basic Pitch (Fixed Logic)."""
148
+ if not MIDI_AVAILABLE:
149
+ return
150
  out_dir = out_path.parent
151
+
152
+ # 1. Run prediction (Standard arguments only)
153
+ predict_and_save(
154
+ audio_path_list=[str(audio_path)],
155
+ output_directory=str(out_dir),
156
+ save_midi=True,
157
+ save_model_outputs=False,
158
+ save_notes=False,
159
+ sonify_midi=False
160
+ )
161
+
162
+ # 2. Find and Rename
163
+ # Basic Pitch creates: <original_name>_basic_pitch.mid
164
+ src_name = audio_path.stem
165
+ generated_file = out_dir / f"{src_name}_basic_pitch.mid"
166
+
167
+ if generated_file.exists():
168
+ if out_path.exists():
169
+ try:
170
+ os.remove(out_path)
171
+ except OSError:
172
+ pass # Continue if we can't delete
173
+ shutil.move(str(generated_file), str(out_path))
174
+
175
+
176
+ # ==========================================
177
+ # 4. AUDIO PROCESSING
178
+ # ==========================================
179
+
180
  def apply_loudness(seg: AudioSegment, mode: str, target: float = -14.0) -> AudioSegment:
181
  mode = (mode or "none").lower().strip()
182
+
183
  if mode == "none": return seg
184
  if mode == "peak": return seg.normalize()
185
  if mode == "rms":
186
  change = target - seg.dBFS
187
  return seg.apply_gain(change)
188
+
189
  if mode == "lufs":
190
+ try:
191
+ samples = np.array(seg.get_array_of_samples())
192
+ if seg.channels == 2:
193
+ samples = samples.reshape((-1, 2))
194
+ samples_float = samples.astype(np.float64) / 32768.0
195
+
196
+ meter = pyln.Meter(seg.frame_rate)
197
+ loudness = meter.integrated_loudness(samples_float)
198
+
199
+ if loudness == -float('inf'): return seg
200
+
201
+ gain_db = target - loudness
202
+ gain_db = max(min(gain_db, 20.0), -20.0)
203
+ return seg.apply_gain(gain_db)
204
+ except Exception:
205
+ return seg
206
  return seg
207
 
208
+
209
  def extract_one_shots(drum_stem_path, bpm, out_dir, loudness_mode, target_dbfs):
210
  y, sr = librosa.load(str(drum_stem_path), sr=None)
211
  onset_frames = librosa.onset.onset_detect(y=y, sr=sr, backtrack=True)
212
  onset_times = librosa.frames_to_time(onset_frames, sr=sr)
213
+
214
  audio = AudioSegment.from_wav(str(drum_stem_path))
215
  hits = []
216
+
217
  for i in range(len(onset_times)):
218
  start_ms = int(onset_times[i] * 1000)
219
+ if i < len(onset_times) - 1:
220
+ next_ms = int(onset_times[i+1] * 1000)
221
+ dur = min(next_ms - start_ms, 450)
222
+ else:
223
+ dur = 450
224
+
225
  hit = audio[start_ms : start_ms + dur]
226
  if hit.rms > 100 and len(hit) > 30:
227
  hits.append(hit.fade_out(10))
228
+
229
  hits.sort(key=lambda x: x.rms, reverse=True)
230
  hits = hits[:32]
231
+
232
+ out_dir.mkdir(parents=True, exist_ok=True)
233
  for i, hit in enumerate(hits):
234
  hit = apply_loudness(hit, mode=loudness_mode, target=target_dbfs)
235
  hit.export(out_dir / f"DrumShot_{i+1:02d}.wav", format="wav")
236
 
237
+
238
+ # ==========================================
239
+ # 5. LOOP ENGINE
240
+ # ==========================================
241
+
242
+ def make_quantized_loops(
243
+ stem_path, stem_name, bpm, key, bar_starts_ms, bar_lengths,
244
+ hop_bars, loops_per, top_k, fade_ms, loop_seam, seam_ms,
245
+ min_bar_gap, loudness_mode, target_dbfs, out_dir
246
+ ):
247
+ if not stem_path.exists():
248
+ return []
249
+
250
  audio = AudioSegment.from_wav(str(stem_path))
251
  ms_per_bar = (240000.0 / bpm)
252
+ trim_win = 8
253
  extra_ms = (seam_ms if loop_seam else 0) + (trim_win * 2)
254
+
255
  grid = bar_starts_ms[::max(1, int(hop_bars))] if bar_starts_ms else []
256
  candidates = []
257
 
 
266
 
267
  candidates.sort(key=lambda x: x[0], reverse=True)
268
  if top_k > 0: candidates = candidates[:int(top_k)]
269
+
270
  selected = []
271
  used_bars = []
272
+
273
  for score, start, blen in candidates:
274
  b_idx = int(np.argmin([abs(start - b) for b in bar_starts_ms]))
275
  if any(abs(b_idx - u) < min_bar_gap for u in used_bars): continue
 
278
  if len(selected) >= loops_per: break
279
 
280
  exported = []
281
+ out_dir.mkdir(parents=True, exist_ok=True)
282
+
283
  for i, (_, start, blen) in enumerate(selected, 1):
284
  t_dur = int(ms_per_bar * blen)
285
  x_dur = t_dur + extra_ms
286
  loop = audio[start : start + x_dur]
287
+
288
+ if len(loop) > trim_win * 2: loop = loop[trim_win : -trim_win]
289
+
290
+ if loop_seam and len(loop) > seam_ms * 2:
291
  head = loop[:seam_ms]
292
  tail = loop[-seam_ms:]
293
  body = loop[seam_ms:-seam_ms]
 
295
  else:
296
  loop = loop[:t_dur]
297
  if fade_ms > 0: loop = loop.fade_in(fade_ms).fade_out(fade_ms)
298
+
299
  loop = loop[:t_dur]
300
  loop = apply_loudness(loop, mode=loudness_mode, target=target_dbfs)
301
+
302
  fname = f"{bpm}BPM_{key}_{stem_name}_L{blen}bars_{i:02d}.wav"
303
  out_path = out_dir / fname
304
  loop.export(out_path, format="wav")
305
  exported.append(out_path)
306
+
307
  return exported
308
 
309
+
310
+ # ==========================================
311
+ # 6. MAIN LOGIC
312
+ # ==========================================
313
+
314
  def analyze_and_separate(file_in, url_in, mode, manual_bpm):
315
  if TEMP_DIR.exists(): shutil.rmtree(TEMP_DIR, ignore_errors=True)
316
  TEMP_DIR.mkdir(parents=True, exist_ok=True)
317
+
318
  fpath = download_from_url(url_in) if url_in else file_in
319
+ if not fpath: raise gr.Error("No Audio Source Provided.")
320
  fpath = safe_copy_to_temp(fpath)
321
  fpath = ensure_wav(fpath)
322
 
323
+ if manual_bpm:
324
+ bpm = int(manual_bpm)
325
+ else:
326
+ y, sr = librosa.load(fpath, duration=60)
327
+ tempo, _ = librosa.beat.beat_track(y=y, sr=sr)
328
+ bpm = int(tempo[0] if np.ndim(tempo) > 0 else tempo)
329
+
330
  key = detect_key(fpath)
331
 
332
  cmd = [sys.executable, "-m", "demucs", "-n", "htdemucs_6s" if mode=="6stem" else "htdemucs", "--out", str(TEMP_DIR), fpath]
333
  if mode == "2stem": cmd += ["--two-stems", "vocals"]
334
+
335
  run_demucs(cmd)
336
 
337
  track_dir = next((TEMP_DIR / ("htdemucs_6s" if mode=="6stem" else "htdemucs")).iterdir())
 
341
  "Piano": track_dir/"piano.wav", "Guitar": track_dir/"guitar.wav",
342
  "Instrumental": track_dir/"no_vocals.wav"
343
  }
 
 
344
 
345
+ valid_stems = [k for k,v in stem_map.items() if v.exists()]
346
+ loops_defaults = [s for s in valid_stems if s != "Vocals"]
 
347
 
348
+ cb_export = gr.CheckboxGroup(choices=valid_stems, value=valid_stems)
349
+ cb_loops = gr.CheckboxGroup(choices=valid_stems, value=loops_defaults)
 
 
350
 
351
+ p_d = str(stem_map["Drums"]) if "Drums" in valid_stems else None
352
+ p_b = str(stem_map["Bass"]) if "Bass" in valid_stems else None
353
+ p_v = str(stem_map["Vocals"]) if "Vocals" in valid_stems else None
 
 
 
 
 
 
354
 
355
+ info_text = f"### 🎵 Detected: {bpm} BPM | Key: {key}"
356
+ return (p_d, p_b, p_v, info_text, bpm, key, str(track_dir), mode, cb_export, cb_loops)
357
+
358
+
359
+ def package_and_export(
360
+ track_folder, bpm, key, stem_mode, art,
361
+ ex_stems, loop_stems, do_midi, do_oneshots, do_vocal_chops,
362
+ loops_per, bars, hop, topk, fadems, loopseam, seamms, mingap,
363
+ loud_mode, loud_target, vid_fmt
364
+ ):
365
  if not track_folder: raise gr.Error("Run Phase 1 First.")
366
 
367
  if OUTPUT_DIR.exists(): shutil.rmtree(OUTPUT_DIR, ignore_errors=True)
 
376
  "Instrumental": t_dir/"no_vocals.wav"
377
  }
378
 
 
379
  for s in ex_stems:
380
  if stems.get(s, Path("x")).exists():
381
  shutil.copy(stems[s], OUTPUT_DIR/"Stems"/f"{bpm}BPM_{key}_{s}.wav")
382
+
383
  if do_midi and MIDI_AVAILABLE:
384
  for s in ["Bass", "Piano", "Guitar", "Other"]:
385
+ if stems.get(s, Path("x")).exists():
386
+ extract_midi(stems[s], OUTPUT_DIR/"MIDI"/f"{bpm}BPM_{key}_{s}.mid")
387
+
388
  if do_oneshots and stems.get("Drums", Path("x")).exists():
389
  extract_one_shots(stems["Drums"], bpm, OUTPUT_DIR/"OneShots", loud_mode, loud_target)
390
 
 
395
  y, sr = librosa.load(str(grid_src), sr=22050, duration=240)
396
  _, beats = librosa.beat.beat_track(y=y, sr=sr)
397
  beat_times = librosa.frames_to_time(beats, sr=sr)
398
+
399
+ if len(beat_times) < 8:
400
+ ms_per_beat = 60000.0 / bpm
401
+ total_len_ms = (len(y) / sr) * 1000
402
+ bar_starts = [int(i * (ms_per_beat * 4)) for i in range(int(total_len_ms // (ms_per_beat * 4)))]
403
+ else:
404
+ bar_starts = [int(t*1000) for t in beat_times[::4]]
405
+
406
  bar_ints = sorted([int(b) for b in bars])
407
 
408
  all_loops = {}
409
  for s in loop_stems:
410
  if s == "Vocals" and do_vocal_chops: continue
411
  if stems.get(s, Path("x")).exists():
412
+ exported = make_quantized_loops(
413
+ stems[s], s, bpm, key, bar_starts, bar_ints, hop, loops_per, topk,
414
+ fadems, loopseam, seamms, mingap, loud_mode, loud_target, OUTPUT_DIR/"Loops"
415
+ )
416
  all_loops[s] = exported
417
 
418
  if do_vocal_chops and stems.get("Vocals", Path("x")).exists():
419
+ exported = make_quantized_loops(
420
+ stems["Vocals"], "Vocals_Chop", bpm, key, bar_starts, [1, 2], 1, 30, 0,
421
+ fadems, False, 0, 0, loud_mode, loud_target, OUTPUT_DIR/"Vocal_Chops"
422
+ )
423
  all_loops["Vocals"] = exported
424
 
 
425
  vid_path = None
426
  if art and any(all_loops.values()):
427
  for k in ["Other", "Synths", "Piano", "Guitar", "Instrumental", "Bass", "Drums"]:
 
430
  break
431
 
432
  print(f"Rendering Video ({vid_fmt})...")
433
+ res_map = {"9:16 (TikTok/Reels)": (1080, 1920), "16:9 (YouTube)": (1920, 1080), "1:1 (Square)": (1080, 1080)}
 
 
 
 
434
  w, h = res_map.get(vid_fmt, (1080, 1920))
435
 
436
  clip = AudioFileClip(str(a_path))
 
437
  bg_clip = ImageClip(art)
438
  img_w, img_h = bg_clip.size
439
 
 
442
 
443
  if img_aspect > target_aspect:
444
  bg_clip = bg_clip.resize(height=h)
445
+ crop_x = (bg_clip.w - w) // 2
 
446
  bg_clip = bg_clip.crop(x1=crop_x, width=w)
447
  else:
448
  bg_clip = bg_clip.resize(width=w)
449
+ crop_y = (bg_clip.h - h) // 2
 
450
  bg_clip = bg_clip.crop(y1=crop_y, height=h)
451
 
452
  bg_clip = bg_clip.resize(lambda t: 1 + 0.02*t).set_position("center").set_duration(clip.duration)
453
+ bar = ColorClip(size=(w, 20), color=(255,255,255)).set_opacity(0.8)
 
 
454
  bar = bar.set_position(lambda t: (int(-w + w*(t/clip.duration)), h - 50))
455
  bar = bar.set_duration(clip.duration)
456
 
457
  final = CompositeVideoClip([bg_clip, bar], size=(w,h))
458
  final.audio = clip
459
+ vid_path = str(OUTPUT_DIR / "Promo.mp4")
460
  final.write_videofile(vid_path, fps=24, codec="libx264", audio_codec="aac", logger=None)
461
 
462
  z_path = "NightPulse_Ultimate.zip"
463
  with zipfile.ZipFile(z_path, "w") as zf:
464
  for r, _, fs in os.walk(OUTPUT_DIR):
465
+ for f in fs:
466
+ zf.write(Path(r)/f, Path(r).relative_to(OUTPUT_DIR)/f)
467
 
468
  return z_path, vid_path
469
 
470
+
471
+ # ==========================================
472
+ # 7. UI WIRING
473
+ # ==========================================
474
+
475
  with gr.Blocks(title="Night Pulse | Ultimate") as app:
476
  gr.Markdown("# 🎹 Night Pulse | Studio Ultimate")
477
 
 
478
  folder = gr.State()
479
  bpm_st = gr.State()
480
  key_st = gr.State()
481
  mode_st = gr.State()
482
 
483
  with gr.Row():
 
484
  with gr.Column(scale=1):
485
+ gr.Markdown("### 1. Source Material")
486
  with gr.Tabs():
487
+ with gr.Tab("Link"):
488
+ url = gr.Textbox(label="YouTube/SoundCloud URL")
489
+ with gr.Tab("File"):
490
+ file = gr.Audio(type="filepath", label="Upload File")
491
 
492
+ mode = gr.Dropdown([("2 Stems (Vox+Inst)", "2stem"), ("4 Stems (Basic)", "4stem"), ("6 Stems (Full)", "6stem")], value="6stem", label="Separation Quality")
493
  mbpm = gr.Number(label="Manual BPM (Optional)")
494
 
 
495
  with gr.Row():
496
  do_midi = gr.Checkbox(label="MIDI", value=True)
497
  do_oneshots = gr.Checkbox(label="Drum Shots", value=True)
498
  do_vox = gr.Checkbox(label="Vocal Chops", value=True)
499
 
500
+ btn1 = gr.Button("🚀 Phase 1: Analyze & Separate", variant="primary")
501
 
 
502
  with gr.Column(scale=1):
503
+ gr.Markdown("### 2. Refine")
504
+ info = gr.Markdown("Waiting...")
505
+ ex_stems = gr.CheckboxGroup(label="Export Stems")
506
+ lp_stems = gr.CheckboxGroup(label="Loop Targets")
 
 
507
 
 
508
  with gr.Row():
509
+ p1 = gr.Audio(label="Drums")
510
+ p2 = gr.Audio(label="Bass")
511
+ p3 = gr.Audio(label="Vocals")
512
 
513
  gr.Markdown("---")
514
 
515
  with gr.Row():
 
516
  with gr.Column(scale=1):
517
+ gr.Markdown("### 3. Engine")
518
  with gr.Row():
519
  loops_per = gr.Slider(1, 40, 12, 1, label="Loops Count")
520
  hop = gr.Slider(1, 8, 1, 1, label="Hop (Bars)")
521
+ bars = gr.CheckboxGroup(["1","2","4","8"], ["4","8"], label="Lengths")
522
 
523
+ art = gr.Image(type="filepath", label="Cover Art")
524
  vid_fmt = gr.Dropdown(["9:16 (TikTok/Reels)", "16:9 (YouTube)", "1:1 (Square)"], value="9:16 (TikTok/Reels)", label="Video Format")
525
 
526
+ with gr.Accordion("Advanced", open=False):
527
  l_mode = gr.Dropdown(["none", "peak", "rms", "lufs"], "lufs", label="Norm Mode")
528
+ l_target = gr.Slider(-24, -5, -14, 1, label="Target")
529
  fadems = gr.Slider(0, 50, 10, label="Fade ms")
530
+ seam = gr.Checkbox(True, label="Seamless")
531
  seamms = gr.Slider(0, 100, 20, label="Seam ms")
532
  mingap = gr.Slider(0,16,4, label="De-Dup Gap")
533
  topk = gr.Slider(0, 100, 30, 1, label="Top K")
534
 
535
+ btn2 = gr.Button("📦 Phase 2: Package", variant="primary")
536
 
 
537
  with gr.Column(scale=1):
538
+ gr.Markdown("### 4. Download")
539
+ z_out = gr.File(label="Zip Pack")
540
+ v_out = gr.Video(label="Promo Video")
541
 
 
542
  def p1_wrap(f, u, m, b):
543
+ d, ba, v, info_txt, bpm, key, pth, md, c1, c2 = analyze_and_separate(f, u, m, b)
544
+ return d, ba, v, info_txt, bpm, key, pth, md, c1, c2
545
 
546
  btn1.click(p1_wrap, [file, url, mode, mbpm], [p1, p2, p3, info, bpm_st, key_st, folder, mode_st, ex_stems, lp_stems])
547
 
 
548
  btn2.click(package_and_export,
549
  [folder, bpm_st, key_st, mode_st, art, ex_stems, lp_stems, do_midi, do_oneshots, do_vox,
550
  loops_per, bars, hop, topk, fadems, seam, seamms, mingap, l_mode, l_target, vid_fmt],
551
  [z_out, v_out])
552
 
553
  if __name__ == "__main__":
554
+ app.launch()