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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +122 -482
app.py CHANGED
@@ -17,8 +17,6 @@ from datetime import datetime
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,531 +25,249 @@ except ImportError:
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,79 +279,48 @@ def package_and_export(
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),
641
  "16:9 (YouTube)": (1920, 1080),
@@ -645,69 +330,49 @@ def package_and_export(
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
 
652
- # Calculate aspect ratios
653
  target_aspect = w / h
654
  img_aspect = img_w / img_h
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,18 +382,11 @@ with gr.Blocks(title="Night Pulse | Ultimate") as app:
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,10 +400,9 @@ with gr.Blocks(title="Night Pulse | Ultimate") as app:
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,19 +418,13 @@ with gr.Blocks(title="Night Pulse | Ultimate") as app:
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,29 +443,18 @@ with gr.Blocks(title="Night Pulse | Ultimate") as app:
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)
 
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
  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
  "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
 
 
 
292
  grid_src = stems.get("Drums")
293
  if not grid_src or not grid_src.exists():
294
+ grid_src = next((stems[k] for k in stems if stems[k].exists()), None)
295
+
 
 
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"]:
319
  if all_loops.get(k):
320
  a_path = all_loops[k][0]
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),
 
330
 
331
  clip = AudioFileClip(str(a_path))
332
 
 
333
  bg_clip = ImageClip(art)
334
  img_w, img_h = bg_clip.size
335
 
 
336
  target_aspect = w / h
337
  img_aspect = img_w / img_h
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()
 
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")
 
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
 
 
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")
 
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)