SaltProphet commited on
Commit
293e81a
·
verified ·
1 Parent(s): 4d13b20

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +141 -65
app.py CHANGED
@@ -21,6 +21,20 @@ OUTPUT_DIR = Path("nightpulse_output")
21
  TEMP_DIR = Path("temp_processing")
22
 
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  # -----------------------------
25
  # Cloud Import
26
  # -----------------------------
@@ -30,6 +44,11 @@ def download_from_url(url):
30
  return None
31
 
32
  print(f"Fetching URL: {url}")
 
 
 
 
 
33
  ydl_opts = {
34
  "format": "bestaudio/best",
35
  "outtmpl": str(TEMP_DIR / "%(title)s.%(ext)s"),
@@ -40,10 +59,6 @@ def download_from_url(url):
40
  "no_warnings": True,
41
  }
42
 
43
- if TEMP_DIR.exists():
44
- shutil.rmtree(TEMP_DIR)
45
- TEMP_DIR.mkdir(parents=True, exist_ok=True)
46
-
47
  with yt_dlp.YoutubeDL(ydl_opts) as ydl:
48
  info = ydl.extract_info(url, download=True)
49
  filename = ydl.prepare_filename(info)
@@ -51,13 +66,63 @@ def download_from_url(url):
51
  return str(final_path)
52
 
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  # -----------------------------
55
  # BPM + Grid
56
  # -----------------------------
57
  def detect_bpm_multiwindow(audio_path, windows=((0, 60), (60, 60), (120, 60))):
58
  """
59
  Multi-window BPM detection: sample multiple slices and take median.
60
- windows = tuples of (offset_seconds, duration_seconds)
61
  """
62
  bpms = []
63
  for offset, dur in windows:
@@ -79,8 +144,7 @@ def detect_bpm_multiwindow(audio_path, windows=((0, 60), (60, 60), (120, 60))):
79
 
80
  def detect_bar_grid(audio_path, bpm, sr=22050, max_seconds=240):
81
  """
82
- Returns bar start times in ms using beat tracking (every 4 beats = 1 bar in 4/4).
83
- Fallback: synth grid from BPM if beat tracking is weak.
84
  """
85
  y, sr = librosa.load(audio_path, sr=sr, mono=True, duration=max_seconds)
86
 
@@ -101,22 +165,15 @@ def detect_bar_grid(audio_path, bpm, sr=22050, max_seconds=240):
101
 
102
 
103
  # -----------------------------
104
- # Loudness + Click/Loop Handling
105
  # -----------------------------
106
  def rms_dbfs(seg: AudioSegment) -> float:
107
- """Approximate RMS loudness in dBFS for ranking."""
108
  if seg.rms <= 0:
109
  return -120.0
110
  return 20.0 * float(np.log10(seg.rms / 32768.0))
111
 
112
 
113
  def apply_loudness(seg: AudioSegment, mode: str, target_dbfs: float = -14.0) -> AudioSegment:
114
- """
115
- mode:
116
- - "none": no change
117
- - "peak": peak normalize (pydub normalize)
118
- - "rms": adjust RMS-ish toward target_dbfs (clamped)
119
- """
120
  mode = (mode or "none").lower().strip()
121
  if mode == "none":
122
  return seg
@@ -131,7 +188,7 @@ def apply_loudness(seg: AudioSegment, mode: str, target_dbfs: float = -14.0) ->
131
 
132
 
133
  def trim_tiny(seg: AudioSegment, window_ms: int = 8) -> AudioSegment:
134
- """Shave a few ms off both ends to reduce clicks, then rely on fades/seam."""
135
  if len(seg) <= window_ms * 2:
136
  return seg
137
  return seg[window_ms:-window_ms]
@@ -139,7 +196,8 @@ def trim_tiny(seg: AudioSegment, window_ms: int = 8) -> AudioSegment:
139
 
140
  def loop_seam_crossfade(seg: AudioSegment, seam_ms=20) -> AudioSegment:
141
  """
142
- Loop-safe seam: blend the end into the start so repeats are cleaner than fade-out/fade-in.
 
143
  """
144
  seam_ms = int(seam_ms)
145
  if seam_ms <= 0 or len(seg) <= seam_ms * 2:
@@ -147,18 +205,18 @@ def loop_seam_crossfade(seg: AudioSegment, seam_ms=20) -> AudioSegment:
147
  head = seg[:seam_ms]
148
  tail = seg[-seam_ms:]
149
  body = seg[seam_ms:-seam_ms]
 
 
150
  blended = tail.append(head, crossfade=seam_ms)
 
 
151
  return body.append(blended, crossfade=seam_ms)
152
 
153
 
154
  # -----------------------------
155
- # De-dup for Variety
156
  # -----------------------------
157
  def dedupe_by_bar_spacing(candidates, bar_starts_ms, min_bar_gap=4):
158
- """
159
- candidates: list[(score, start_ms, bar_len)]
160
- Keep only candidates whose bar start is at least min_bar_gap away from previously selected.
161
- """
162
  if not bar_starts_ms:
163
  return candidates
164
 
@@ -176,7 +234,7 @@ def dedupe_by_bar_spacing(candidates, bar_starts_ms, min_bar_gap=4):
176
 
177
 
178
  # -----------------------------
179
- # Loop Engine
180
  # -----------------------------
181
  def make_quantized_loops(
182
  stem_path: Path,
@@ -195,7 +253,6 @@ def make_quantized_loops(
195
  target_dbfs: float,
196
  out_dir: Path
197
  ):
198
- """Generate multiple loops per stem, quantized to bar grid, ranked by RMS, de-duped by spacing."""
199
  if not stem_path.exists():
200
  return []
201
 
@@ -208,42 +265,65 @@ def make_quantized_loops(
208
  fade_ms = int(fade_ms)
209
  seam_ms = int(seam_ms)
210
  min_bar_gap = int(min_bar_gap)
 
 
 
 
 
 
 
 
211
 
212
  grid = bar_starts_ms[::hop_bars] if bar_starts_ms else []
213
  candidates = []
214
 
215
  for bar_len in bar_lengths:
216
- dur_ms = ms_per_bar * int(bar_len)
 
 
217
  for start_ms in grid:
218
- if start_ms + dur_ms > len(audio):
219
  continue
220
- seg = audio[start_ms:start_ms + dur_ms]
221
- if len(seg) < dur_ms:
 
 
 
222
  continue
 
 
223
  candidates.append((rms_dbfs(seg), int(start_ms), int(bar_len)))
224
 
225
  candidates.sort(key=lambda x: x[0], reverse=True)
226
 
227
- # limit candidates before dedupe
228
  if int(top_k) > 0:
229
  candidates = candidates[:int(top_k)]
230
 
231
- # variety dedupe
232
  candidates = dedupe_by_bar_spacing(candidates, bar_starts_ms, min_bar_gap=min_bar_gap)
233
 
234
  exported = []
235
  for rank, (score, start_ms, bar_len) in enumerate(candidates[:loops_per_stem], start=1):
236
- dur_ms = ms_per_bar * int(bar_len)
237
- loop = audio[start_ms:start_ms + dur_ms]
 
 
238
 
239
- loop = trim_tiny(loop, window_ms=8)
 
240
 
 
241
  if loop_seam:
242
  loop = loop_seam_crossfade(loop, seam_ms=seam_ms)
243
  else:
 
 
244
  if fade_ms > 0:
245
  loop = loop.fade_in(fade_ms).fade_out(fade_ms)
246
 
 
 
 
 
247
  loop = apply_loudness(loop, mode=loudness_mode, target_dbfs=float(target_dbfs))
248
 
249
  if bar_starts_ms:
@@ -298,6 +378,7 @@ def vocal_chops_silence(
298
  scored = [(rms_dbfs(c), c) for c in kept]
299
  scored.sort(key=lambda x: x[0], reverse=True)
300
 
 
301
  exported = []
302
  for i, (score, c) in enumerate(scored[:int(max_chops)], start=1):
303
  c = trim_tiny(c, window_ms=8)
@@ -371,6 +452,7 @@ def vocal_chops_onset(
371
  scored = [(rms_dbfs(s), s) for s in segments]
372
  scored.sort(key=lambda x: x[0], reverse=True)
373
 
 
374
  exported = []
375
  for i, (score, seg) in enumerate(scored[:int(max_chops)], start=1):
376
  seg = trim_tiny(seg, window_ms=8)
@@ -397,10 +479,6 @@ def vocal_chops_grid(
397
  target_dbfs: float = -14.0,
398
  rms_gate: int = 200
399
  ):
400
- """
401
- Grid chops snapped to BPM. Great for producer packs.
402
- grid_size: half | 1beat | 2beat | 1bar
403
- """
404
  if not vocals_path.exists():
405
  return []
406
 
@@ -424,6 +502,7 @@ def vocal_chops_grid(
424
 
425
  chops.sort(key=lambda x: x[0], reverse=True)
426
 
 
427
  exported = []
428
  for i, (score, seg) in enumerate(chops[:int(max_chops)], start=1):
429
  seg = trim_tiny(seg, 6)
@@ -443,12 +522,6 @@ def vocal_chops_grid(
443
  # Demucs Modes + Stem Mapping
444
  # -----------------------------
445
  def demucs_command(model_mode: str, audio_file: str):
446
- """
447
- Supported modes:
448
- - "2stem": vocals + instrumental (no_vocals)
449
- - "4stem": drums/bass/other/vocals
450
- - "6stem": drums/bass/guitar/piano/other/vocals
451
- """
452
  model_mode = (model_mode or "6stem").lower().strip()
453
 
454
  if model_mode == "2stem":
@@ -488,8 +561,16 @@ def map_stems(track_folder: Path, mode: str):
488
  # Phase 1: Analyze + Separate
489
  # -----------------------------
490
  def analyze_and_separate(file_input, url_input, stem_mode, manual_bpm):
491
- audio_file = None
 
 
 
 
 
 
 
492
 
 
493
  if url_input and len(url_input) > 5:
494
  print("Using Cloud Import...")
495
  try:
@@ -504,13 +585,15 @@ def analyze_and_separate(file_input, url_input, stem_mode, manual_bpm):
504
  raise gr.Error("No audio source found. Paste a link or upload a file.")
505
 
506
  try:
507
- # cleanup output dirs
508
  if OUTPUT_DIR.exists():
509
- shutil.rmtree(OUTPUT_DIR)
510
  (OUTPUT_DIR / "Stems").mkdir(parents=True, exist_ok=True)
511
  (OUTPUT_DIR / "Loops").mkdir(parents=True, exist_ok=True)
512
  TEMP_DIR.mkdir(parents=True, exist_ok=True)
513
 
 
 
 
514
  # BPM
515
  if manual_bpm and int(manual_bpm) > 0:
516
  bpm = int(manual_bpm)
@@ -522,12 +605,19 @@ def analyze_and_separate(file_input, url_input, stem_mode, manual_bpm):
522
  bpm = max(40, min(220, int(bpm)))
523
  print(f"Using BPM: {bpm}")
524
 
525
- # Demucs separation
526
  cmd, demucs_model_folder = demucs_command(stem_mode, audio_file)
527
  print(f"Separating stems (mode={stem_mode})...")
528
- subprocess.run(cmd, check=True, capture_output=True)
 
 
 
 
 
 
 
 
 
529
 
530
- # locate track folder
531
  demucs_out = TEMP_DIR / demucs_model_folder
532
  track_folder = next(demucs_out.iterdir(), None)
533
  if not track_folder:
@@ -535,7 +625,6 @@ def analyze_and_separate(file_input, url_input, stem_mode, manual_bpm):
535
 
536
  stems = map_stems(track_folder, stem_mode)
537
 
538
- # preview outputs (6 slots)
539
  p_drums = str(stems["Drums"]) if "Drums" in stems and stems["Drums"].exists() else None
540
  p_bass = str(stems["Bass"]) if "Bass" in stems and stems["Bass"].exists() else None
541
  p_guitar = str(stems["Guitar"]) if "Guitar" in stems and stems["Guitar"].exists() else None
@@ -552,9 +641,6 @@ def analyze_and_separate(file_input, url_input, stem_mode, manual_bpm):
552
 
553
  return p_drums, p_bass, p_guitar, p_piano, p_other, p_vocals, bpm, str(track_folder), stem_mode
554
 
555
- except subprocess.CalledProcessError as e:
556
- err = e.stderr.decode("utf-8", errors="ignore") if e.stderr else str(e)
557
- raise gr.Error(f"Demucs Failed: {err}")
558
  except Exception as e:
559
  raise gr.Error(f"Process Failed: {str(e)}")
560
 
@@ -593,9 +679,8 @@ def package_and_export(
593
  bpm = int(bpm)
594
  stems = map_stems(track_folder, stem_mode)
595
 
596
- # cleanup output dirs
597
  if OUTPUT_DIR.exists():
598
- shutil.rmtree(OUTPUT_DIR)
599
  (OUTPUT_DIR / "Stems").mkdir(parents=True, exist_ok=True)
600
  (OUTPUT_DIR / "Loops").mkdir(parents=True, exist_ok=True)
601
  (OUTPUT_DIR / "Vocal_Chops").mkdir(parents=True, exist_ok=True)
@@ -603,12 +688,10 @@ def package_and_export(
603
  export_stems = set(export_stems or [])
604
  loop_stems = set(loop_stems or [])
605
 
606
- # export full stems (selected only)
607
  for name, path in stems.items():
608
  if name in export_stems and path.exists():
609
  shutil.copy(path, OUTPUT_DIR / "Stems" / f"{bpm}BPM_Full_{name}.wav")
610
 
611
- # build bar grid source preference
612
  grid_source = None
613
  for k in ("Drums", "Synths", "Instrumental", "Vocals", "Bass"):
614
  if k in stems and stems[k].exists():
@@ -619,7 +702,6 @@ def package_and_export(
619
 
620
  bar_starts_ms = detect_bar_grid(str(grid_source), bpm=bpm, max_seconds=240)
621
 
622
- # parse bar lengths
623
  if not bar_lengths:
624
  bar_lengths = ["4", "8"]
625
  bar_lengths_int = sorted(list({int(x) for x in bar_lengths if str(x).strip().isdigit()}))
@@ -629,7 +711,6 @@ def package_and_export(
629
  loops_dir = OUTPUT_DIR / "Loops"
630
  all_loops = {}
631
 
632
- # generate loops only for selected stems (excluding vocals)
633
  for stem_name, stem_path in stems.items():
634
  if stem_name == "Vocals":
635
  continue
@@ -657,7 +738,6 @@ def package_and_export(
657
  )
658
  all_loops[stem_name] = exported
659
 
660
- # vocals: chops (if enabled) else (optional) treat as loops if user included Vocals in loop list
661
  vocal_exports = []
662
  if "Vocals" in stems and stems["Vocals"].exists():
663
  if enable_vocal_chops:
@@ -702,7 +782,6 @@ def package_and_export(
702
  else:
703
  vocal_exports = []
704
  else:
705
- # if chops disabled, user may still want vocal loops (treat vocals like a loop stem only if selected)
706
  if "Vocals" in loop_stems:
707
  vocal_exports = make_quantized_loops(
708
  stem_path=stems["Vocals"],
@@ -724,7 +803,6 @@ def package_and_export(
724
 
725
  all_loops["Vocals"] = vocal_exports
726
 
727
- # Promo video chooses first melodic-ish loop
728
  video_loop = None
729
  for key in ("Synths", "Piano", "Guitar", "Instrumental"):
730
  if all_loops.get(key):
@@ -752,7 +830,6 @@ def package_and_export(
752
  final_clip.write_videofile(str(vid_out), codec="libx264", audio_codec="aac", logger=None)
753
  video_path = str(vid_out)
754
 
755
- # Manifest (so you can reproduce packs later)
756
  manifest = {
757
  "created_at": datetime.utcnow().isoformat() + "Z",
758
  "bpm": bpm,
@@ -780,7 +857,6 @@ def package_and_export(
780
  }
781
  (OUTPUT_DIR / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
782
 
783
- # Zip pack
784
  zip_file = "NightPulse_Pack.zip"
785
  with zipfile.ZipFile(zip_file, "w") as zf:
786
  for root, dirs, files in os.walk(OUTPUT_DIR):
@@ -958,4 +1034,4 @@ with gr.Blocks(title="Night Pulse | Studio Pro") as app:
958
  )
959
 
960
  if __name__ == "__main__":
961
- app.launch()
 
21
  TEMP_DIR = Path("temp_processing")
22
 
23
 
24
+ # -----------------------------
25
+ # Startup Checks
26
+ # -----------------------------
27
+ def check_ffmpeg():
28
+ """Ensure FFmpeg is installed and accessible."""
29
+ if shutil.which("ffmpeg") is None:
30
+ print("CRITICAL WARNING: FFmpeg not found in system PATH.")
31
+ print("Audio processing (pydub/demucs) will fail.")
32
+ return False
33
+ return True
34
+
35
+ check_ffmpeg()
36
+
37
+
38
  # -----------------------------
39
  # Cloud Import
40
  # -----------------------------
 
44
  return None
45
 
46
  print(f"Fetching URL: {url}")
47
+ # Clean temp before new download to avoid collisions
48
+ if TEMP_DIR.exists():
49
+ shutil.rmtree(TEMP_DIR, ignore_errors=True)
50
+ TEMP_DIR.mkdir(parents=True, exist_ok=True)
51
+
52
  ydl_opts = {
53
  "format": "bestaudio/best",
54
  "outtmpl": str(TEMP_DIR / "%(title)s.%(ext)s"),
 
59
  "no_warnings": True,
60
  }
61
 
 
 
 
 
62
  with yt_dlp.YoutubeDL(ydl_opts) as ydl:
63
  info = ydl.extract_info(url, download=True)
64
  filename = ydl.prepare_filename(info)
 
66
  return str(final_path)
67
 
68
 
69
+ # -----------------------------
70
+ # File Handling (Safer)
71
+ # -----------------------------
72
+ def safe_copy_to_temp(audio_file: str) -> str:
73
+ """
74
+ Copy source file into TEMP_DIR with a safe filename (avoids path/space/unicode surprises).
75
+ """
76
+ src = Path(audio_file)
77
+ TEMP_DIR.mkdir(parents=True, exist_ok=True)
78
+ safe_stem = "".join(c if c.isalnum() or c in "._-" else "_" for c in src.stem)
79
+ dst = TEMP_DIR / f"{safe_stem}{src.suffix.lower()}"
80
+ try:
81
+ shutil.copy(src, dst)
82
+ except Exception:
83
+ return str(src)
84
+ return str(dst)
85
+
86
+
87
+ def ensure_wav(input_path: str) -> str:
88
+ """
89
+ Convert input audio to WAV for Demucs reliability.
90
+ """
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
+ audio = AudioSegment.from_file(str(p))
98
+ audio.export(str(out), format="wav")
99
+ return str(out)
100
+
101
+
102
+ # -----------------------------
103
+ # Demucs Runner
104
+ # -----------------------------
105
+ def run_demucs(cmd):
106
+ """
107
+ Run demucs and return stdout. If it fails, raise gr.Error with stdout/stderr tail.
108
+ """
109
+ p = subprocess.run(cmd, capture_output=True, text=True)
110
+ if p.returncode != 0:
111
+ raise gr.Error(
112
+ "Demucs failed.\n\n"
113
+ f"Command:\n{cmd}\n\n"
114
+ f"STDOUT (tail):\n{(p.stdout or '')[-4000:]}\n\n"
115
+ f"STDERR (tail):\n{(p.stderr or '')[-4000:]}"
116
+ )
117
+ return p.stdout or ""
118
+
119
+
120
  # -----------------------------
121
  # BPM + Grid
122
  # -----------------------------
123
  def detect_bpm_multiwindow(audio_path, windows=((0, 60), (60, 60), (120, 60))):
124
  """
125
  Multi-window BPM detection: sample multiple slices and take median.
 
126
  """
127
  bpms = []
128
  for offset, dur in windows:
 
144
 
145
  def detect_bar_grid(audio_path, bpm, sr=22050, max_seconds=240):
146
  """
147
+ Returns bar start times in ms.
 
148
  """
149
  y, sr = librosa.load(audio_path, sr=sr, mono=True, duration=max_seconds)
150
 
 
165
 
166
 
167
  # -----------------------------
168
+ # Loudness + Processing
169
  # -----------------------------
170
  def rms_dbfs(seg: AudioSegment) -> float:
 
171
  if seg.rms <= 0:
172
  return -120.0
173
  return 20.0 * float(np.log10(seg.rms / 32768.0))
174
 
175
 
176
  def apply_loudness(seg: AudioSegment, mode: str, target_dbfs: float = -14.0) -> AudioSegment:
 
 
 
 
 
 
177
  mode = (mode or "none").lower().strip()
178
  if mode == "none":
179
  return seg
 
188
 
189
 
190
  def trim_tiny(seg: AudioSegment, window_ms: int = 8) -> AudioSegment:
191
+ """Shave window_ms off BOTH ends."""
192
  if len(seg) <= window_ms * 2:
193
  return seg
194
  return seg[window_ms:-window_ms]
 
196
 
197
  def loop_seam_crossfade(seg: AudioSegment, seam_ms=20) -> AudioSegment:
198
  """
199
+ Takes the tail (seam_ms) and crossfades it into the head.
200
+ This reduces total length by seam_ms.
201
  """
202
  seam_ms = int(seam_ms)
203
  if seam_ms <= 0 or len(seg) <= seam_ms * 2:
 
205
  head = seg[:seam_ms]
206
  tail = seg[-seam_ms:]
207
  body = seg[seam_ms:-seam_ms]
208
+
209
+ # Append head to tail (blending)
210
  blended = tail.append(head, crossfade=seam_ms)
211
+
212
+ # Reattach to body
213
  return body.append(blended, crossfade=seam_ms)
214
 
215
 
216
  # -----------------------------
217
+ # De-dup
218
  # -----------------------------
219
  def dedupe_by_bar_spacing(candidates, bar_starts_ms, min_bar_gap=4):
 
 
 
 
220
  if not bar_starts_ms:
221
  return candidates
222
 
 
234
 
235
 
236
  # -----------------------------
237
+ # Loop Engine (FIXED DRIFT)
238
  # -----------------------------
239
  def make_quantized_loops(
240
  stem_path: Path,
 
253
  target_dbfs: float,
254
  out_dir: Path
255
  ):
 
256
  if not stem_path.exists():
257
  return []
258
 
 
265
  fade_ms = int(fade_ms)
266
  seam_ms = int(seam_ms)
267
  min_bar_gap = int(min_bar_gap)
268
+
269
+ # Calculate extra audio needed to compensate for trim and seam
270
+ # trim_tiny removes 2x window (8ms start, 8ms end)
271
+ trim_window = 8
272
+ needed_extra = 0
273
+ if loop_seam:
274
+ needed_extra += seam_ms
275
+ needed_extra += (trim_window * 2)
276
 
277
  grid = bar_starts_ms[::hop_bars] if bar_starts_ms else []
278
  candidates = []
279
 
280
  for bar_len in bar_lengths:
281
+ target_dur_ms = ms_per_bar * int(bar_len)
282
+ extract_dur_ms = target_dur_ms + needed_extra
283
+
284
  for start_ms in grid:
285
+ if start_ms + extract_dur_ms > len(audio):
286
  continue
287
+
288
+ # Extract WITH the buffer
289
+ seg = audio[start_ms : start_ms + extract_dur_ms]
290
+
291
+ if len(seg) < extract_dur_ms:
292
  continue
293
+
294
+ # Score based on RMS
295
  candidates.append((rms_dbfs(seg), int(start_ms), int(bar_len)))
296
 
297
  candidates.sort(key=lambda x: x[0], reverse=True)
298
 
 
299
  if int(top_k) > 0:
300
  candidates = candidates[:int(top_k)]
301
 
 
302
  candidates = dedupe_by_bar_spacing(candidates, bar_starts_ms, min_bar_gap=min_bar_gap)
303
 
304
  exported = []
305
  for rank, (score, start_ms, bar_len) in enumerate(candidates[:loops_per_stem], start=1):
306
+ target_dur_ms = ms_per_bar * int(bar_len)
307
+ extract_dur_ms = target_dur_ms + needed_extra
308
+
309
+ loop = audio[start_ms : start_ms + extract_dur_ms]
310
 
311
+ # 1. Trim Tiny (removes trim_window from start and end)
312
+ loop = trim_tiny(loop, window_ms=trim_window)
313
 
314
+ # 2. Seam or Fade
315
  if loop_seam:
316
  loop = loop_seam_crossfade(loop, seam_ms=seam_ms)
317
  else:
318
+ # If no seam, we just have extra audio hanging off the end. Trim it.
319
+ loop = loop[:target_dur_ms]
320
  if fade_ms > 0:
321
  loop = loop.fade_in(fade_ms).fade_out(fade_ms)
322
 
323
+ # 3. Final Hard Quantize (Critical for DAW sync)
324
+ # Force length to be exactly the grid length
325
+ loop = loop[:target_dur_ms]
326
+
327
  loop = apply_loudness(loop, mode=loudness_mode, target_dbfs=float(target_dbfs))
328
 
329
  if bar_starts_ms:
 
378
  scored = [(rms_dbfs(c), c) for c in kept]
379
  scored.sort(key=lambda x: x[0], reverse=True)
380
 
381
+ out_dir.mkdir(parents=True, exist_ok=True)
382
  exported = []
383
  for i, (score, c) in enumerate(scored[:int(max_chops)], start=1):
384
  c = trim_tiny(c, window_ms=8)
 
452
  scored = [(rms_dbfs(s), s) for s in segments]
453
  scored.sort(key=lambda x: x[0], reverse=True)
454
 
455
+ out_dir.mkdir(parents=True, exist_ok=True)
456
  exported = []
457
  for i, (score, seg) in enumerate(scored[:int(max_chops)], start=1):
458
  seg = trim_tiny(seg, window_ms=8)
 
479
  target_dbfs: float = -14.0,
480
  rms_gate: int = 200
481
  ):
 
 
 
 
482
  if not vocals_path.exists():
483
  return []
484
 
 
502
 
503
  chops.sort(key=lambda x: x[0], reverse=True)
504
 
505
+ out_dir.mkdir(parents=True, exist_ok=True)
506
  exported = []
507
  for i, (score, seg) in enumerate(chops[:int(max_chops)], start=1):
508
  seg = trim_tiny(seg, 6)
 
522
  # Demucs Modes + Stem Mapping
523
  # -----------------------------
524
  def demucs_command(model_mode: str, audio_file: str):
 
 
 
 
 
 
525
  model_mode = (model_mode or "6stem").lower().strip()
526
 
527
  if model_mode == "2stem":
 
561
  # Phase 1: Analyze + Separate
562
  # -----------------------------
563
  def analyze_and_separate(file_input, url_input, stem_mode, manual_bpm):
564
+ # --- CRITICAL FIX: CLEAN UP OLD RUNS TO PREVENT GHOST STEMS ---
565
+ if TEMP_DIR.exists():
566
+ try:
567
+ shutil.rmtree(TEMP_DIR, ignore_errors=True)
568
+ except Exception:
569
+ pass
570
+ TEMP_DIR.mkdir(parents=True, exist_ok=True)
571
+ # -------------------------------------------------------------
572
 
573
+ audio_file = None
574
  if url_input and len(url_input) > 5:
575
  print("Using Cloud Import...")
576
  try:
 
585
  raise gr.Error("No audio source found. Paste a link or upload a file.")
586
 
587
  try:
 
588
  if OUTPUT_DIR.exists():
589
+ shutil.rmtree(OUTPUT_DIR, ignore_errors=True)
590
  (OUTPUT_DIR / "Stems").mkdir(parents=True, exist_ok=True)
591
  (OUTPUT_DIR / "Loops").mkdir(parents=True, exist_ok=True)
592
  TEMP_DIR.mkdir(parents=True, exist_ok=True)
593
 
594
+ audio_file = safe_copy_to_temp(audio_file)
595
+ audio_file = ensure_wav(audio_file)
596
+
597
  # BPM
598
  if manual_bpm and int(manual_bpm) > 0:
599
  bpm = int(manual_bpm)
 
605
  bpm = max(40, min(220, int(bpm)))
606
  print(f"Using BPM: {bpm}")
607
 
 
608
  cmd, demucs_model_folder = demucs_command(stem_mode, audio_file)
609
  print(f"Separating stems (mode={stem_mode})...")
610
+ try:
611
+ run_demucs(cmd)
612
+ except gr.Error as e:
613
+ if (stem_mode or "").lower().strip() == "6stem":
614
+ print("6-stem failed; falling back to 4-stem htdemucs...")
615
+ stem_mode = "4stem"
616
+ cmd, demucs_model_folder = demucs_command(stem_mode, audio_file)
617
+ run_demucs(cmd)
618
+ else:
619
+ raise
620
 
 
621
  demucs_out = TEMP_DIR / demucs_model_folder
622
  track_folder = next(demucs_out.iterdir(), None)
623
  if not track_folder:
 
625
 
626
  stems = map_stems(track_folder, stem_mode)
627
 
 
628
  p_drums = str(stems["Drums"]) if "Drums" in stems and stems["Drums"].exists() else None
629
  p_bass = str(stems["Bass"]) if "Bass" in stems and stems["Bass"].exists() else None
630
  p_guitar = str(stems["Guitar"]) if "Guitar" in stems and stems["Guitar"].exists() else None
 
641
 
642
  return p_drums, p_bass, p_guitar, p_piano, p_other, p_vocals, bpm, str(track_folder), stem_mode
643
 
 
 
 
644
  except Exception as e:
645
  raise gr.Error(f"Process Failed: {str(e)}")
646
 
 
679
  bpm = int(bpm)
680
  stems = map_stems(track_folder, stem_mode)
681
 
 
682
  if OUTPUT_DIR.exists():
683
+ shutil.rmtree(OUTPUT_DIR, ignore_errors=True)
684
  (OUTPUT_DIR / "Stems").mkdir(parents=True, exist_ok=True)
685
  (OUTPUT_DIR / "Loops").mkdir(parents=True, exist_ok=True)
686
  (OUTPUT_DIR / "Vocal_Chops").mkdir(parents=True, exist_ok=True)
 
688
  export_stems = set(export_stems or [])
689
  loop_stems = set(loop_stems or [])
690
 
 
691
  for name, path in stems.items():
692
  if name in export_stems and path.exists():
693
  shutil.copy(path, OUTPUT_DIR / "Stems" / f"{bpm}BPM_Full_{name}.wav")
694
 
 
695
  grid_source = None
696
  for k in ("Drums", "Synths", "Instrumental", "Vocals", "Bass"):
697
  if k in stems and stems[k].exists():
 
702
 
703
  bar_starts_ms = detect_bar_grid(str(grid_source), bpm=bpm, max_seconds=240)
704
 
 
705
  if not bar_lengths:
706
  bar_lengths = ["4", "8"]
707
  bar_lengths_int = sorted(list({int(x) for x in bar_lengths if str(x).strip().isdigit()}))
 
711
  loops_dir = OUTPUT_DIR / "Loops"
712
  all_loops = {}
713
 
 
714
  for stem_name, stem_path in stems.items():
715
  if stem_name == "Vocals":
716
  continue
 
738
  )
739
  all_loops[stem_name] = exported
740
 
 
741
  vocal_exports = []
742
  if "Vocals" in stems and stems["Vocals"].exists():
743
  if enable_vocal_chops:
 
782
  else:
783
  vocal_exports = []
784
  else:
 
785
  if "Vocals" in loop_stems:
786
  vocal_exports = make_quantized_loops(
787
  stem_path=stems["Vocals"],
 
803
 
804
  all_loops["Vocals"] = vocal_exports
805
 
 
806
  video_loop = None
807
  for key in ("Synths", "Piano", "Guitar", "Instrumental"):
808
  if all_loops.get(key):
 
830
  final_clip.write_videofile(str(vid_out), codec="libx264", audio_codec="aac", logger=None)
831
  video_path = str(vid_out)
832
 
 
833
  manifest = {
834
  "created_at": datetime.utcnow().isoformat() + "Z",
835
  "bpm": bpm,
 
857
  }
858
  (OUTPUT_DIR / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
859
 
 
860
  zip_file = "NightPulse_Pack.zip"
861
  with zipfile.ZipFile(zip_file, "w") as zf:
862
  for root, dirs, files in os.walk(OUTPUT_DIR):
 
1034
  )
1035
 
1036
  if __name__ == "__main__":
1037
+ app.launch()