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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +486 -131
app.py CHANGED
@@ -17,6 +17,8 @@ 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
@@ -25,249 +27,531 @@ except ImportError:
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
- # -----------------------------
 
 
39
  def check_ffmpeg():
 
 
 
 
40
  if shutil.which("ffmpeg") is None:
41
  print("CRITICAL WARNING: FFmpeg not found in system PATH.")
 
42
  return False
43
  return True
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"),
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
 
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]
185
- if len(seg) < x_dur: continue
 
 
 
 
 
186
  candidates.append((seg.dBFS, int(start_ms), int(bar_len)))
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
 
 
 
 
195
  selected.append((score, start, blen))
196
  used_bars.append(b_idx)
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]
 
209
  loop = body.append(tail.append(head, crossfade=seam_ms), crossfade=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)
273
 
@@ -279,45 +563,78 @@ 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["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),
@@ -328,7 +645,7 @@ def package_and_export(track_folder, bpm, key, stem_mode, art,
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
 
@@ -338,47 +655,59 @@ def package_and_export(track_folder, bpm, key, stem_mode, art,
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):
371
- for f in fs: zf.write(Path(r)/f, Path(r).relative_to(OUTPUT_DIR)/f)
 
 
 
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()
@@ -388,11 +717,18 @@ with gr.Blocks(title="Night Pulse | Ultimate") as app:
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")
@@ -406,9 +742,10 @@ with gr.Blocks(title="Night Pulse | Ultimate") as app:
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
 
@@ -424,13 +761,19 @@ with gr.Blocks(title="Night Pulse | Ultimate") as app:
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")
@@ -449,17 +792,29 @@ with gr.Blocks(title="Night Pulse | Ultimate") as app:
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__":
465
  app.launch(ssr_mode=False)
 
17
  import pyloudnorm as pyln
18
 
19
  # --- OPTIONAL: MIDI IMPORT ---
20
+ # We wrap this in a try/except block so the app doesn't crash if the user
21
+ # hasn't installed the heavy 'basic-pitch' library yet.
22
  try:
23
  from basic_pitch.inference import predict_and_save
24
  MIDI_AVAILABLE = True
 
27
  print("WARNING: 'basic-pitch' not installed. MIDI extraction will be disabled.")
28
 
29
  # --- PATCH FOR PILLOW 10.0+ ---
30
+ # MoviePy v1.0.3 relies on 'ANTIALIAS', which was removed in Pillow 10.0.
31
+ # We monkey-patch it here to prevent the 'AttributeError'.
32
  import PIL.Image
33
  if not hasattr(PIL.Image, 'ANTIALIAS'):
34
  PIL.Image.ANTIALIAS = PIL.Image.LANCZOS
35
 
36
+ # --- CONFIGURATION ---
37
  OUTPUT_DIR = Path("nightpulse_output")
38
  TEMP_DIR = Path("temp_processing")
39
 
40
+
41
+ # ==========================================
42
+ # 1. SYSTEM UTILITIES
43
+ # ==========================================
44
+
45
  def check_ffmpeg():
46
+ """
47
+ Checks if FFmpeg is installed and accessible in the system PATH.
48
+ Audio processing libraries (pydub, demucs) heavily rely on this.
49
+ """
50
  if shutil.which("ffmpeg") is None:
51
  print("CRITICAL WARNING: FFmpeg not found in system PATH.")
52
+ print("Audio processing will likely fail.")
53
  return False
54
  return True
55
 
56
+ # Run check on startup
57
  check_ffmpeg()
58
 
59
+
60
+ # ==========================================
61
+ # 2. AUDIO ANALYSIS ENGINES
62
+ # ==========================================
63
+
64
  def detect_key(audio_path):
65
+ """
66
+ Estimates the musical key (e.g., 'Cmaj', 'F#min') using Librosa chroma features.
67
+ It compares the audio's pitch profile against standard major/minor profiles.
68
+ """
69
  try:
70
+ # Load audio (first 60 seconds is usually enough for key detection)
71
  y, sr = librosa.load(str(audio_path), sr=None, duration=60)
72
+
73
+ # Extract Pitch Class Profile (Chroma)
74
  chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
75
  chroma_vals = np.sum(chroma, axis=1)
76
+
77
+ # Standard Krumhansl-Schmuckler profiles for Major and Minor keys
78
  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]
79
  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]
80
+
81
  pitches = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
82
+
83
  best_score = -1
84
  best_key = "Unknown"
85
+
86
+ # Test all 12 pitches as the tonic
87
  for i in range(12):
88
+ # Shift profiles to align with the current pitch 'i'
89
  p_maj = np.roll(maj_profile, i)
90
  p_min = np.roll(min_profile, i)
91
+
92
+ # Calculate correlation
93
  score_maj = np.corrcoef(chroma_vals, p_maj)[0, 1]
94
  score_min = np.corrcoef(chroma_vals, p_min)[0, 1]
95
+
96
  if score_maj > best_score:
97
  best_score = score_maj
98
  best_key = f"{pitches[i]}maj"
99
+
100
  if score_min > best_score:
101
  best_score = score_min
102
  best_key = f"{pitches[i]}min"
103
+
104
  return best_key
105
+ except Exception as e:
106
+ print(f"Key detection warning: {e}")
107
  return "Unknown"
108
 
109
+
110
+ # ==========================================
111
+ # 3. FILE HANDLING & DOWNLOADS
112
+ # ==========================================
113
+
114
  def download_from_url(url):
115
+ """
116
+ Downloads audio from YouTube, SoundCloud, etc., using yt-dlp.
117
+ Returns the path to the downloaded WAV file.
118
+ """
119
+ if not url:
120
+ return None
121
+
122
+ print(f"Fetching URL: {url}")
123
+
124
+ # clear temp dir to prevent filename collisions
125
+ if TEMP_DIR.exists():
126
+ shutil.rmtree(TEMP_DIR, ignore_errors=True)
127
  TEMP_DIR.mkdir(parents=True, exist_ok=True)
128
+
129
  ydl_opts = {
130
  "format": "bestaudio/best",
131
  "outtmpl": str(TEMP_DIR / "%(title)s.%(ext)s"),
132
+ "postprocessors": [
133
+ {"key": "FFmpegExtractAudio", "preferredcodec": "wav", "preferredquality": "192"}
134
+ ],
135
+ "quiet": True,
136
+ "no_warnings": True,
137
  }
138
+
139
  with yt_dlp.YoutubeDL(ydl_opts) as ydl:
140
  info = ydl.extract_info(url, download=True)
141
  filename = ydl.prepare_filename(info)
142
+ final_path = Path(filename).with_suffix(".wav")
143
+ return str(final_path)
144
+
145
 
146
  def safe_copy_to_temp(audio_file: str) -> str:
147
+ """
148
+ Copies an uploaded file to the temp directory with a sanitized filename.
149
+ This prevents issues with spaces or special characters in shell commands.
150
+ """
151
  src = Path(audio_file)
152
  TEMP_DIR.mkdir(parents=True, exist_ok=True)
153
+
154
+ # Replace non-alphanumeric chars with underscores
155
  safe_stem = "".join(c if c.isalnum() or c in "._-" else "_" for c in src.stem)
156
  dst = TEMP_DIR / f"{safe_stem}{src.suffix.lower()}"
157
+
158
+ try:
159
+ shutil.copy(src, dst)
160
+ except Exception:
161
+ # Fallback if copy fails (e.g., same file)
162
+ return str(src)
163
  return str(dst)
164
 
165
+
166
  def ensure_wav(input_path: str) -> str:
167
+ """
168
+ Ensures the input is a WAV file. If not (mp3, m4a, etc.), converts it.
169
+ Demucs works best with WAV input.
170
+ """
171
  p = Path(input_path)
172
+ if p.suffix.lower() == ".wav":
173
+ return str(p)
174
+
175
  TEMP_DIR.mkdir(parents=True, exist_ok=True)
176
  out = TEMP_DIR / f"{p.stem}.wav"
177
+
178
+ audio = AudioSegment.from_file(str(p))
179
+ audio.export(str(out), format="wav")
180
  return str(out)
181
 
182
+
183
+ # ==========================================
184
+ # 4. EXTERNAL TOOLS (DEMUCS & MIDI)
185
+ # ==========================================
186
+
187
  def run_demucs(cmd):
188
+ """
189
+ Executes the Demucs command line tool via subprocess.
190
+ Captures stdout/stderr to display errors in the UI if it crashes.
191
+ """
192
+ print(f"Running Demucs: {' '.join(cmd)}")
193
  p = subprocess.run(cmd, capture_output=True, text=True)
194
+
195
+ if p.returncode != 0:
196
+ # Pass the error log back to Gradio
197
+ raise gr.Error(f"Demucs Separation Failed:\n\n{p.stderr[-2000:]}")
198
  return p.stdout
199
 
200
+
201
  def extract_midi(audio_path, out_path):
202
+ """
203
+ Uses Spotify's 'basic-pitch' library to convert a monophonic or polyphonic
204
+ stem (like Bass or Piano) into a MIDI file.
205
+ """
206
+ if not MIDI_AVAILABLE:
207
+ return
208
+
209
+ # Basic-pitch saves to a directory, usually naming the file based on the input.
210
  out_dir = out_path.parent
211
  name = out_path.stem
212
+
213
+ try:
214
+ predict_and_save(
215
+ audio_path_list=[str(audio_path)],
216
+ output_directory=str(out_dir),
217
+ save_midi=True,
218
+ save_model_outputs=False,
219
+ save_notes=False,
220
+ sonify_midi=False,
221
+ # Force the name if supported by this version of basic-pitch
222
+ save_midi_path=str(out_path)
223
+ )
224
+
225
+ # Basic-pitch might ignore 'save_midi_path' in some versions and use
226
+ # the default name "<stem>_basic_pitch.mid". We check for that.
227
+ expected_default = out_dir / f"{name}_basic_pitch.mid"
228
+ if expected_default.exists() and expected_default != out_path:
229
+ shutil.move(expected_default, out_path)
230
+
231
+ except Exception as e:
232
+ print(f"MIDI Extraction failed for {audio_path.name}: {e}")
233
+
234
+
235
+ # ==========================================
236
+ # 5. AUDIO PROCESSING (LOUDNESS & ONE SHOTS)
237
+ # ==========================================
238
 
 
 
 
239
  def apply_loudness(seg: AudioSegment, mode: str, target: float = -14.0) -> AudioSegment:
240
+ """
241
+ Normalizes audio using Peak, RMS, or integrated LUFS (Standard).
242
+ """
243
  mode = (mode or "none").lower().strip()
244
+
245
+ if mode == "none":
246
+ return seg
247
+
248
+ if mode == "peak":
249
+ return seg.normalize()
250
+
251
  if mode == "rms":
252
+ # Simple dBFS adjustment
253
  change = target - seg.dBFS
254
  return seg.apply_gain(change)
255
+
256
  if mode == "lufs":
257
+ # Professional standard (Broadcast loudness)
258
+ try:
259
+ # Get raw samples as numpy array
260
+ samples = np.array(seg.get_array_of_samples())
261
+
262
+ # Pyloudnorm expects float data between -1.0 and 1.0
263
+ # AudioSegment uses 16-bit int (usually), so we divide by 32768.0
264
+ if seg.channels == 2:
265
+ samples = samples.reshape((-1, 2))
266
+
267
+ samples_float = samples.astype(np.float64) / 32768.0
268
+
269
+ meter = pyln.Meter(seg.frame_rate)
270
+ loudness = meter.integrated_loudness(samples_float)
271
+
272
+ if loudness == -float('inf'):
273
+ return seg # Silence, skip
274
+
275
+ gain_db = target - loudness
276
+
277
+ # Safety clamp to prevent exploding headers
278
+ gain_db = max(min(gain_db, 20.0), -20.0)
279
+
280
+ return seg.apply_gain(gain_db)
281
+ except Exception as e:
282
+ print(f"LUFS normalization failed: {e}")
283
+ return seg
284
+
285
  return seg
286
 
287
+
288
  def extract_one_shots(drum_stem_path, bpm, out_dir, loudness_mode, target_dbfs):
289
+ """
290
+ Slices the 'Drums' stem into individual hits (Kick, Snare, etc.) using Onset Detection.
291
+ """
292
+ # Load raw audio for analysis
293
  y, sr = librosa.load(str(drum_stem_path), sr=None)
294
+
295
+ # Detect onset frames (start of hits)
296
  onset_frames = librosa.onset.onset_detect(y=y, sr=sr, backtrack=True)
297
  onset_times = librosa.frames_to_time(onset_frames, sr=sr)
298
+
299
+ # Load Pydub object for slicing
300
  audio = AudioSegment.from_wav(str(drum_stem_path))
301
  hits = []
302
+
303
  for i in range(len(onset_times)):
304
  start_ms = int(onset_times[i] * 1000)
305
+
306
+ # Determine duration: either until the next hit, or max 450ms
307
+ if i < len(onset_times) - 1:
308
+ next_ms = int(onset_times[i+1] * 1000)
309
+ dur = min(next_ms - start_ms, 450)
310
+ else:
311
+ dur = 450
312
+
313
  hit = audio[start_ms : start_ms + dur]
314
+
315
+ # Filter out ghost notes or tiny noise
316
  if hit.rms > 100 and len(hit) > 30:
317
+ # Tiny fade out to prevent clicking at the cut
318
+ hit = hit.fade_out(10)
319
+ hits.append(hit)
320
+
321
+ # Sort by loudness to ensure the main Kicks/Snares are at the top of the list
322
  hits.sort(key=lambda x: x.rms, reverse=True)
323
+
324
+ # Keep top 32 hits to avoid clutter
325
  hits = hits[:32]
326
+
327
+ out_dir.mkdir(parents=True, exist_ok=True)
328
+
329
  for i, hit in enumerate(hits):
330
  hit = apply_loudness(hit, mode=loudness_mode, target=target_dbfs)
331
  hit.export(out_dir / f"DrumShot_{i+1:02d}.wav", format="wav")
332
 
333
+
334
+ # ==========================================
335
+ # 6. LOOP GENERATION ENGINE
336
+ # ==========================================
337
+
338
+ def make_quantized_loops(
339
+ stem_path, stem_name, bpm, key, bar_starts_ms, bar_lengths,
340
+ hop_bars, loops_per, top_k, fade_ms, loop_seam, seam_ms,
341
+ min_bar_gap, loudness_mode, target_dbfs, out_dir
342
+ ):
343
+ """
344
+ The core engine. Slices stems into loops based on bar grid, ranks them by loudness,
345
+ and applies crossfades/normalization.
346
+ """
347
+ if not stem_path.exists():
348
+ return []
349
+
350
  audio = AudioSegment.from_wav(str(stem_path))
351
  ms_per_bar = (240000.0 / bpm)
352
+
353
+ # Seam/Trim Buffer Logic
354
+ trim_win = 8 # ms to shave off ends to prevent zero-crossing clicks
355
+
356
+ # If we are crossfading the seam, we need extra audio at the end
357
  extra_ms = (seam_ms if loop_seam else 0) + (trim_win * 2)
358
+
359
+ # Generate grid points
360
  grid = bar_starts_ms[::max(1, int(hop_bars))] if bar_starts_ms else []
361
  candidates = []
362
 
363
  for bar_len in bar_lengths:
364
  t_dur = int(ms_per_bar * bar_len)
365
  x_dur = t_dur + extra_ms
366
+
367
  for start_ms in grid:
368
+ # Check boundaries
369
+ if start_ms + x_dur > len(audio):
370
+ continue
371
+
372
  seg = audio[start_ms : start_ms + x_dur]
373
+
374
+ # Double check length
375
+ if len(seg) < x_dur:
376
+ continue
377
+
378
+ # Store candidate with loudness score
379
  candidates.append((seg.dBFS, int(start_ms), int(bar_len)))
380
 
381
+ # Sort by loudness (loudest/busiest loops first)
382
  candidates.sort(key=lambda x: x[0], reverse=True)
383
+
384
+ # Filter top K
385
+ if top_k > 0:
386
+ candidates = candidates[:int(top_k)]
387
+
388
+ # De-duplicate (don't pick loops that are too close to each other)
389
  selected = []
390
  used_bars = []
391
+
392
  for score, start, blen in candidates:
393
  b_idx = int(np.argmin([abs(start - b) for b in bar_starts_ms]))
394
+
395
+ # If this bar index is close to an already used one, skip
396
+ if any(abs(b_idx - u) < min_bar_gap for u in used_bars):
397
+ continue
398
+
399
  selected.append((score, start, blen))
400
  used_bars.append(b_idx)
401
+
402
+ if len(selected) >= loops_per:
403
+ break
404
 
405
+ # Process and Export
406
  exported = []
407
+ out_dir.mkdir(parents=True, exist_ok=True)
408
+
409
  for i, (_, start, blen) in enumerate(selected, 1):
410
  t_dur = int(ms_per_bar * blen)
411
  x_dur = t_dur + extra_ms
412
+
413
  loop = audio[start : start + x_dur]
414
+
415
+ # 1. Trim Tiny
416
+ if len(loop) > trim_win * 2:
417
+ loop = loop[trim_win : -trim_win]
418
+
419
+ # 2. Seam Crossfade
420
+ if loop_seam and len(loop) > seam_ms * 2:
421
  head = loop[:seam_ms]
422
  tail = loop[-seam_ms:]
423
  body = loop[seam_ms:-seam_ms]
424
+ # Blend tail into head
425
  loop = body.append(tail.append(head, crossfade=seam_ms), crossfade=seam_ms)
426
  else:
427
+ # Hard crop
428
  loop = loop[:t_dur]
429
+ if fade_ms > 0:
430
+ loop = loop.fade_in(fade_ms).fade_out(fade_ms)
431
+
432
+ # 3. Final Length Quantize
433
  loop = loop[:t_dur]
434
+
435
+ # 4. Loudness
436
  loop = apply_loudness(loop, mode=loudness_mode, target=target_dbfs)
437
+
438
+ # 5. Filename
439
  fname = f"{bpm}BPM_{key}_{stem_name}_L{blen}bars_{i:02d}.wav"
440
  out_path = out_dir / fname
441
+
442
  loop.export(out_path, format="wav")
443
  exported.append(out_path)
444
+
445
  return exported
446
 
447
+
448
+ # ==========================================
449
+ # 7. PHASE 1: ANALYZE & SEPARATE
450
+ # ==========================================
451
+
452
  def analyze_and_separate(file_in, url_in, mode, manual_bpm):
453
+ """
454
+ Handles file ingest, BPM/Key detection, and Demucs separation.
455
+ Returns preview paths and sets up the UI for Phase 2.
456
+ """
457
+ # 1. Prepare Temp Dir
458
+ if TEMP_DIR.exists():
459
+ shutil.rmtree(TEMP_DIR, ignore_errors=True)
460
  TEMP_DIR.mkdir(parents=True, exist_ok=True)
461
+
462
+ # 2. Ingest
463
  fpath = download_from_url(url_in) if url_in else file_in
464
+ if not fpath:
465
+ raise gr.Error("No Audio Source Provided.")
466
+
467
  fpath = safe_copy_to_temp(fpath)
468
  fpath = ensure_wav(fpath)
469
 
470
+ # 3. Analyze (BPM & Key)
471
+ if manual_bpm:
472
+ bpm = int(manual_bpm)
473
+ else:
474
+ # Detect BPM from first 60s
475
+ y, sr = librosa.load(fpath, duration=60)
476
+ tempo, _ = librosa.beat.beat_track(y=y, sr=sr)
477
+ bpm = int(tempo[0] if np.ndim(tempo) > 0 else tempo)
478
+
479
  key = detect_key(fpath)
480
+ print(f"Analysis Complete: {bpm} BPM, Key: {key}")
481
+
482
+ # 4. Separate (Demucs)
483
+ cmd = [
484
+ sys.executable, "-m", "demucs",
485
+ "-n", "htdemucs_6s" if mode=="6stem" else "htdemucs",
486
+ "--out", str(TEMP_DIR),
487
+ fpath
488
+ ]
489
+ if mode == "2stem":
490
+ cmd += ["--two-stems", "vocals"]
491
 
 
 
492
  run_demucs(cmd)
493
 
494
+ # 5. Map Outputs
495
  track_dir = next((TEMP_DIR / ("htdemucs_6s" if mode=="6stem" else "htdemucs")).iterdir())
496
+
497
  stem_map = {
498
+ "Drums": track_dir/"drums.wav",
499
+ "Bass": track_dir/"bass.wav",
500
+ "Vocals": track_dir/"vocals.wav",
501
+ "Other": track_dir/"other.wav",
502
+ "Piano": track_dir/"piano.wav",
503
+ "Guitar": track_dir/"guitar.wav",
504
  "Instrumental": track_dir/"no_vocals.wav"
505
  }
 
 
506
 
507
+ # Determine which stems actually exist
508
+ valid_stems = [k for k,v in stem_map.items() if v.exists()]
 
509
 
510
+ # Smart defaults for checkboxes:
511
+ # Export: All available stems
512
+ # Loops: All available stems EXCEPT Vocals (unless manually added)
513
+ loops_defaults = [s for s in valid_stems if s != "Vocals"]
514
 
515
+ # Create UI update components
516
+ cb_export = gr.CheckboxGroup(choices=valid_stems, value=valid_stems)
517
+ cb_loops = gr.CheckboxGroup(choices=valid_stems, value=loops_defaults)
 
 
 
 
 
 
518
 
519
+ # Get paths for previews (safe handling if stems missing)
520
+ p_d = str(stem_map["Drums"]) if "Drums" in valid_stems else None
521
+ p_b = str(stem_map["Bass"]) if "Bass" in valid_stems else None
522
+ p_v = str(stem_map["Vocals"]) if "Vocals" in valid_stems else None
523
 
524
+ analysis_text = f"### 🎵 Detected: {bpm} BPM | Key: {key}"
525
+
526
+ return (
527
+ p_d, p_b, p_v, # Previews
528
+ analysis_text, # Info Text
529
+ bpm, key, str(track_dir), mode, # Hidden State
530
+ cb_export, cb_loops # Checkbox Updates
531
+ )
532
+
533
+
534
+ # ==========================================
535
+ # 8. PHASE 2: PACKAGE & EXPORT
536
+ # ==========================================
537
+
538
+ def package_and_export(
539
+ track_folder, bpm, key, stem_mode, art,
540
+ ex_stems, loop_stems, do_midi, do_oneshots, do_vocal_chops,
541
+ loops_per, bars, hop, topk, fadems, loopseam, seamms, mingap,
542
+ loud_mode, loud_target, vid_fmt
543
+ ):
544
+ """
545
+ Generates all content (Stems, Loops, MIDI, Video) and zips it up.
546
+ """
547
+
548
+ if not track_folder:
549
+ raise gr.Error("Run Phase 1 First.")
550
+
551
+ # 1. Setup Output Directories
552
+ if OUTPUT_DIR.exists():
553
+ shutil.rmtree(OUTPUT_DIR, ignore_errors=True)
554
+
555
  for d in ["Stems", "Loops", "MIDI", "OneShots", "Vocal_Chops"]:
556
  (OUTPUT_DIR / d).mkdir(parents=True, exist_ok=True)
557
 
 
563
  "Instrumental": t_dir/"no_vocals.wav"
564
  }
565
 
566
+ # 2. Export Full Stems
567
  for s in ex_stems:
568
  if stems.get(s, Path("x")).exists():
569
  shutil.copy(stems[s], OUTPUT_DIR/"Stems"/f"{bpm}BPM_{key}_{s}.wav")
570
+
571
+ # 3. Extract MIDI
572
  if do_midi and MIDI_AVAILABLE:
573
  for s in ["Bass", "Piano", "Guitar", "Other"]:
574
+ if stems.get(s, Path("x")).exists():
575
+ extract_midi(stems[s], OUTPUT_DIR/"MIDI"/f"{bpm}BPM_{key}_{s}.mid")
576
+
577
+ # 4. Extract One Shots
578
+ if do_oneshots and stems.get("Drums", Path("x")).exists():
579
  extract_one_shots(stems["Drums"], bpm, OUTPUT_DIR/"OneShots", loud_mode, loud_target)
580
 
581
+ # 5. Build Grid for Looping
582
+ # Prefer Drums for rhythmic grid, fallback to whatever is available
583
+ grid_src = stems.get("Drums")
584
+ if not grid_src or not grid_src.exists():
585
+ # Fallback to first available stem
586
+ grid_src = next((stems[k] for k in stems if stems[k].exists()), None)
587
+
588
+ # Load audio to detect beats
589
  y, sr = librosa.load(str(grid_src), sr=22050, duration=240)
590
  _, beats = librosa.beat.beat_track(y=y, sr=sr)
591
  beat_times = librosa.frames_to_time(beats, sr=sr)
592
+
593
+ # If beat detection failed or is sparse, use mathematical grid
594
+ if len(beat_times) < 8:
595
+ ms_per_beat = 60000.0 / bpm
596
+ total_len_ms = (len(y) / sr) * 1000
597
+ bar_starts = [int(i * (ms_per_beat * 4)) for i in range(int(total_len_ms // (ms_per_beat * 4)))]
598
+ else:
599
+ # Every 4th beat is a bar start (assuming 4/4 time)
600
+ bar_starts = [int(t*1000) for t in beat_times[::4]]
601
+
602
  bar_ints = sorted([int(b) for b in bars])
603
 
604
+ # 6. Generate Loops (Instrumental)
605
  all_loops = {}
606
  for s in loop_stems:
607
+ if s == "Vocals" and do_vocal_chops:
608
+ continue # Handle vocals separately in chop engine
609
+
610
  if stems.get(s, Path("x")).exists():
611
+ exported = make_quantized_loops(
612
+ stems[s], s, bpm, key, bar_starts, bar_ints, hop, loops_per, topk,
613
+ fadems, loopseam, seamms, mingap, loud_mode, loud_target, OUTPUT_DIR/"Loops"
614
+ )
615
  all_loops[s] = exported
616
 
617
+ # 7. Generate Vocal Chops
618
+ if do_vocal_chops and stems.get("Vocals", Path("x")).exists():
619
+ # We reuse the quantized loop engine but with shorter settings
620
+ # This creates "Vocal Loops" effectively.
621
+ exported = make_quantized_loops(
622
+ stems["Vocals"], "Vocals_Chop", bpm, key, bar_starts, [1, 2], 1, 30, 0,
623
+ fadems, False, 0, 0, loud_mode, loud_target, OUTPUT_DIR/"Vocal_Chops"
624
+ )
625
  all_loops["Vocals"] = exported
626
 
627
+ # 8. Render Social Media Video (Smart Crop)
628
  vid_path = None
629
  if art and any(all_loops.values()):
630
+ # Choose background audio (prefer melodic elements)
631
  for k in ["Other", "Synths", "Piano", "Guitar", "Instrumental", "Bass", "Drums"]:
632
  if all_loops.get(k):
633
  a_path = all_loops[k][0]
634
  break
635
 
636
  print(f"Rendering Video ({vid_fmt})...")
637
+
638
  # Define Resolution
639
  res_map = {
640
  "9:16 (TikTok/Reels)": (1080, 1920),
 
645
 
646
  clip = AudioFileClip(str(a_path))
647
 
648
+ # Load & Smart Crop Image
649
  bg_clip = ImageClip(art)
650
  img_w, img_h = bg_clip.size
651
 
 
655
 
656
  if img_aspect > target_aspect:
657
  # Image is wider than target: resize by height, crop width
 
 
658
  bg_clip = bg_clip.resize(height=h)
659
+ new_w = bg_clip.w
660
  crop_x = (new_w - w) // 2
661
  bg_clip = bg_clip.crop(x1=crop_x, width=w)
662
  else:
663
  # Image is taller/narrower: resize by width, crop height
 
 
664
  bg_clip = bg_clip.resize(width=w)
665
+ new_h = bg_clip.h
666
  crop_y = (new_h - h) // 2
667
  bg_clip = bg_clip.crop(y1=crop_y, height=h)
668
 
669
+ # Add subtle zoom effect
670
  bg_clip = bg_clip.resize(lambda t: 1 + 0.02*t).set_position("center").set_duration(clip.duration)
671
 
672
+ # Add Progress Bar
673
+ bar_h = 20
674
+ # Use ColorClip
675
+ bar = ColorClip(size=(w, bar_h), color=(255,255,255)).set_opacity(0.8)
676
+
677
+ # Animate bar position from left (-width) to right (0)
678
+ # Position is (x, y). y is fixed at bottom minus offset.
679
+ bar_y = h - 100
680
+ bar = bar.set_position(lambda t: (int(-w + w*(t/clip.duration)), bar_y))
681
  bar = bar.set_duration(clip.duration)
682
 
683
  final = CompositeVideoClip([bg_clip, bar], size=(w,h))
684
  final.audio = clip
685
+
686
+ vid_path = str(OUTPUT_DIR / "Promo.mp4")
687
+
688
+ # Write file (using libx264 for video, aac for audio)
689
  final.write_videofile(vid_path, fps=24, codec="libx264", audio_codec="aac", logger=None)
690
 
691
+ # 9. Create Zip Archive
692
  z_path = "NightPulse_Ultimate.zip"
693
  with zipfile.ZipFile(z_path, "w") as zf:
694
  for r, _, fs in os.walk(OUTPUT_DIR):
695
+ for f in fs:
696
+ file_path = Path(r) / f
697
+ arc_name = file_path.relative_to(OUTPUT_DIR)
698
+ zf.write(file_path, arc_name)
699
 
700
  return z_path, vid_path
701
 
702
+
703
+ # ==========================================
704
+ # 9. USER INTERFACE
705
+ # ==========================================
706
+
707
  with gr.Blocks(title="Night Pulse | Ultimate") as app:
708
  gr.Markdown("# 🎹 Night Pulse | Studio Ultimate")
709
 
710
+ # -- HIDDEN STATE --
711
  folder = gr.State()
712
  bpm_st = gr.State()
713
  key_st = gr.State()
 
717
  # --- COL 1: CONFIGURATION ---
718
  with gr.Column(scale=1):
719
  gr.Markdown("### 1. Setup & Source")
720
+
721
  with gr.Tabs():
722
+ with gr.Tab("Link"):
723
+ url = gr.Textbox(label="YouTube/SC URL", placeholder="https://...")
724
+ with gr.Tab("File"):
725
+ file = gr.Audio(type="filepath", label="Upload File")
726
 
727
+ mode = gr.Dropdown(
728
+ [("2 Stems (Vox+Inst)", "2stem"), ("4 Stems (Basic)", "4stem"), ("6 Stems (Full)", "6stem")],
729
+ value="6stem",
730
+ label="Separation Model"
731
+ )
732
  mbpm = gr.Number(label="Manual BPM (Optional)")
733
 
734
  gr.Markdown("#### Extraction Targets")
 
742
  # --- COL 2: REFINEMENT (Dynamic) ---
743
  with gr.Column(scale=1):
744
  gr.Markdown("### 2. Select & Refine")
745
+
746
  info = gr.Markdown("Waiting for analysis...")
747
 
748
+ # These checkboxes update dynamically after Phase 1
749
  ex_stems = gr.CheckboxGroup(label="Export Full Stems")
750
  lp_stems = gr.CheckboxGroup(label="Generate Loops For")
751
 
 
761
  # --- COL 3: EXPORT SETTINGS ---
762
  with gr.Column(scale=1):
763
  gr.Markdown("### 3. Loop Engine & Video")
764
+
765
  with gr.Row():
766
  loops_per = gr.Slider(1, 40, 12, 1, label="Loops Count")
767
  hop = gr.Slider(1, 8, 1, 1, label="Hop (Bars)")
768
+
769
  bars = gr.CheckboxGroup(["1","2","4","8"], ["4","8"], label="Loop Lengths")
770
 
771
  art = gr.Image(type="filepath", label="Cover Art (Auto-Resize)")
772
+ vid_fmt = gr.Dropdown(
773
+ ["9:16 (TikTok/Reels)", "16:9 (YouTube)", "1:1 (Square)"],
774
+ value="9:16 (TikTok/Reels)",
775
+ label="Video Format"
776
+ )
777
 
778
  with gr.Accordion("Advanced Audio", open=False):
779
  l_mode = gr.Dropdown(["none", "peak", "rms", "lufs"], "lufs", label="Norm Mode")
 
792
  z_out = gr.File(label="Complete Pack (Zip)")
793
  v_out = gr.Video(label="Social Media Promo")
794
 
795
+ # --- EVENT WIRING ---
796
+
797
  def p1_wrap(f, u, m, b):
798
+ """Wrapper to unpack results into UI components"""
799
+ d, ba, v, analysis_text, bpm, key, pth, md, c1, c2 = analyze_and_separate(f, u, m, b)
800
+ return d, ba, v, analysis_text, bpm, key, pth, md, c1, c2
801
 
802
+ btn1.click(
803
+ p1_wrap,
804
+ inputs=[file, url, mode, mbpm],
805
+ outputs=[p1, p2, p3, info, bpm_st, key_st, folder, mode_st, ex_stems, lp_stems]
806
+ )
807
 
808
+ btn2.click(
809
+ package_and_export,
810
+ inputs=[
811
+ folder, bpm_st, key_st, mode_st, art,
812
+ ex_stems, lp_stems, do_midi, do_oneshots, do_vox,
813
+ loops_per, bars, hop, topk, fadems, loopseam, seamms, mingap,
814
+ l_mode, l_target, vid_fmt
815
+ ],
816
+ outputs=[z_out, v_out]
817
+ )
818
 
819
  if __name__ == "__main__":
820
  app.launch(ssr_mode=False)