SaltProphet commited on
Commit
0a64583
·
verified ·
1 Parent(s): 6b29282

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +797 -125
app.py CHANGED
@@ -5,6 +5,7 @@ import zipfile
5
  import librosa
6
  import numpy as np
7
  from pydub import AudioSegment
 
8
  from moviepy.editor import AudioFileClip, ImageClip, CompositeVideoClip
9
  # Keep import available but unused (Full feature set available)
10
  from moviepy.video.fx.all import blackwhite, lum_contrast
@@ -12,49 +13,483 @@ import subprocess
12
  from pathlib import Path
13
  import sys
14
  import yt_dlp
 
 
15
 
16
  # --- Configuration ---
17
  OUTPUT_DIR = Path("nightpulse_output")
18
  TEMP_DIR = Path("temp_processing")
19
 
20
- # --- Helper: Cloud Import (The Fix) ---
 
 
 
21
  def download_from_url(url):
22
  """Downloads audio from YouTube/SC/Direct Link to bypass file picker crashes."""
23
- if not url: return None
24
-
 
25
  print(f"Fetching URL: {url}")
26
- # Configuration for high-quality audio extraction
27
  ydl_opts = {
28
- 'format': 'bestaudio/best',
29
- 'outtmpl': str(TEMP_DIR / '%(title)s.%(ext)s'),
30
- 'postprocessors': [{
31
- 'key': 'FFmpegExtractAudio',
32
- 'preferredcodec': 'wav',
33
- 'preferredquality': '192',
34
- }],
35
- 'quiet': True,
36
- 'no_warnings': True,
37
  }
38
-
39
- # Clean temp before downloading
40
- if TEMP_DIR.exists(): shutil.rmtree(TEMP_DIR)
41
  TEMP_DIR.mkdir(parents=True, exist_ok=True)
42
 
43
  with yt_dlp.YoutubeDL(ydl_opts) as ydl:
44
  info = ydl.extract_info(url, download=True)
45
  filename = ydl.prepare_filename(info)
46
- # yt-dlp might change extension to .wav after conversion
47
  final_path = Path(filename).with_suffix(".wav")
48
  return str(final_path)
49
 
50
- # --- Core Logic Functions ---
51
 
52
- def analyze_and_separate(file_input, url_input):
53
- """Phase 1: Separate 6 Stems (Drums, Bass, Guitar, Piano, Vocals, Other)"""
54
-
55
- # LOGIC: Check URL first (Safe Mode), then File (Desktop Mode)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  audio_file = None
57
-
58
  if url_input and len(url_input) > 5:
59
  print("Using Cloud Import...")
60
  try:
@@ -64,130 +499,238 @@ def analyze_and_separate(file_input, url_input):
64
  elif file_input:
65
  print("Using File Upload...")
66
  audio_file = file_input
67
-
68
  if not audio_file:
69
- raise gr.Error("No audio source found. Please paste a link or upload a file.")
70
 
71
  try:
72
- # Cleanup Output
73
- if OUTPUT_DIR.exists(): shutil.rmtree(OUTPUT_DIR)
 
74
  (OUTPUT_DIR / "Stems").mkdir(parents=True, exist_ok=True)
75
  (OUTPUT_DIR / "Loops").mkdir(parents=True, exist_ok=True)
76
- if not TEMP_DIR.exists(): TEMP_DIR.mkdir(parents=True, exist_ok=True)
77
-
78
- filename = Path(audio_file).stem
79
-
80
- # 1. BPM Detection
81
- print(f"Analyzing {filename}...")
82
- y, sr = librosa.load(audio_file, duration=60, mono=True)
83
- tempo, _ = librosa.beat.beat_track(y=y, sr=sr)
84
-
85
- if np.ndim(tempo) > 0:
86
- bpm = int(round(tempo[0]))
87
  else:
88
- bpm = int(round(tempo))
89
- print(f"Detected BPM: {bpm}")
 
90
 
91
- # 2. Demucs Separation (Using 6-Stem Model)
92
- print("Separating stems...")
93
- subprocess.run([
94
- sys.executable, "-m", "demucs", "-n", "htdemucs_6s", "--out", str(TEMP_DIR), audio_file
95
- ], check=True, capture_output=True)
96
 
97
- # 3. Locate Stems
98
- demucs_out = TEMP_DIR / "htdemucs_6s"
 
 
 
 
 
99
  track_folder = next(demucs_out.iterdir(), None)
100
- if not track_folder: raise FileNotFoundError("Demucs separation failed.")
101
-
102
- # Map stems
103
- drums = track_folder / "drums.wav"
104
- bass = track_folder / "bass.wav"
105
- guitar = track_folder / "guitar.wav"
106
- piano = track_folder / "piano.wav"
107
- vocals = track_folder / "vocals.wav"
108
- other = track_folder / "other.wav"
109
-
110
- return str(drums), str(bass), str(guitar), str(piano), str(other), str(vocals), bpm, str(track_folder)
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  except Exception as e:
113
  raise gr.Error(f"Process Failed: {str(e)}")
114
 
 
 
 
 
115
  def package_and_export(
116
  track_folder_str,
117
  bpm,
118
- start_offset_sec, # kept for compatibility, but not used for loop starts anymore
119
  cover_art,
120
- # --- new loop engine params (you can wire these into UI later) ---
121
- loops_per_stem=12,
122
- bar_lengths=(1, 2, 4, 8),
123
- hop_bars=1,
124
- top_k=30,
125
- fade_ms=12,
126
- loudness_mode="none", # "none" | "peak" | "rms"
127
- target_dbfs=-14.0
 
 
 
 
 
 
 
 
 
 
 
 
128
  ):
129
- """Phase 2: Package & Export (real loop engine)"""
130
  try:
131
  track_folder = Path(track_folder_str)
132
- stems = {
133
- "Drums": track_folder / "drums.wav",
134
- "Bass": track_folder / "bass.wav",
135
- "Guitar": track_folder / "guitar.wav",
136
- "Piano": track_folder / "piano.wav",
137
- "Synths": track_folder / "other.wav",
138
- "Vocals": track_folder / "vocals.wav"
139
- }
140
 
141
- # Cleanup Output
142
  if OUTPUT_DIR.exists():
143
  shutil.rmtree(OUTPUT_DIR)
144
  (OUTPUT_DIR / "Stems").mkdir(parents=True, exist_ok=True)
145
  (OUTPUT_DIR / "Loops").mkdir(parents=True, exist_ok=True)
 
 
 
 
146
 
147
- # 1) Save Full Stems
148
  for name, path in stems.items():
149
- if path.exists():
150
  shutil.copy(path, OUTPUT_DIR / "Stems" / f"{bpm}BPM_Full_{name}.wav")
151
 
152
- # 2) Build bar grid from the *master beat grid source*
153
- # Use drums if present (best for beat tracking), otherwise "other", otherwise any existing stem.
154
- grid_source = stems["Drums"] if stems["Drums"].exists() else (
155
- stems["Synths"] if stems["Synths"].exists() else next((p for p in stems.values() if p.exists()), None)
156
- )
 
157
  if grid_source is None:
158
- raise FileNotFoundError("No stems found to build beat/bar grid.")
159
 
160
  bar_starts_ms = detect_bar_grid(str(grid_source), bpm=bpm, max_seconds=240)
161
 
162
- # 3) Create quantized multi-loops per stem
163
- all_exported_loops = {}
 
 
 
 
 
164
  loops_dir = OUTPUT_DIR / "Loops"
 
165
 
 
166
  for stem_name, stem_path in stems.items():
 
 
 
 
 
 
 
167
  exported = make_quantized_loops(
168
  stem_path=stem_path,
169
  stem_name=stem_name,
170
- bpm=int(bpm),
171
  bar_starts_ms=bar_starts_ms,
172
- bar_lengths=list(bar_lengths),
173
  hop_bars=int(hop_bars),
174
- max_loops_per_stem=int(loops_per_stem),
175
  top_k=int(top_k),
176
  fade_ms=int(fade_ms),
 
 
 
177
  loudness_mode=str(loudness_mode),
178
  target_dbfs=float(target_dbfs),
179
  out_dir=loops_dir
180
  )
181
- all_exported_loops[stem_name] = exported
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
 
183
- # Pick a melody-ish loop for video (prefer Synths, fallback Piano/Guitar)
 
 
184
  video_loop = None
185
- for key in ("Synths", "Piano", "Guitar"):
186
- if all_exported_loops.get(key):
187
- video_loop = all_exported_loops[key][0]
188
  break
189
 
190
- # 4) Generate Video (optional)
191
  video_path = None
192
  if cover_art and video_loop:
193
  print("Rendering Video...")
@@ -197,7 +740,7 @@ def package_and_export(
197
 
198
  img = ImageClip(cover_art).resize(width=1080)
199
  img = img.resize(lambda t: 1 + 0.02 * t)
200
- img = img.set_position(('center', 'center'))
201
  img = img.set_duration(duration)
202
  img = img.set_audio(audio_clip)
203
 
@@ -209,9 +752,37 @@ def package_and_export(
209
  final_clip.write_videofile(str(vid_out), codec="libx264", audio_codec="aac", logger=None)
210
  video_path = str(vid_out)
211
 
212
- # 5) Zip
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  zip_file = "NightPulse_Pack.zip"
214
- with zipfile.ZipFile(zip_file, 'w') as zf:
215
  for root, dirs, files in os.walk(OUTPUT_DIR):
216
  for file in files:
217
  file_path = Path(root) / file
@@ -224,31 +795,48 @@ def package_and_export(
224
  raise gr.Error(f"Packaging Failed: {str(e)}")
225
 
226
 
227
-
228
- # --- GUI (Blocks) ---
 
229
  with gr.Blocks(title="Night Pulse | Studio Pro") as app:
230
  gr.Markdown("# 🎛️ Night Pulse | Studio Command Center")
231
- gr.Markdown("Full 6-stem separation and video generation.")
232
-
233
  stored_folder = gr.State()
234
  stored_bpm = gr.State()
235
-
 
236
  with gr.Row():
237
  with gr.Column(scale=1):
238
  gr.Markdown("### 1. Audio Source")
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  with gr.Tabs():
240
  with gr.TabItem("☁️ Import Link (Mobile Safe)"):
241
- gr.Markdown("**Paste a link (YouTube/SoundCloud) to avoid browser reloads.**")
242
- input_url = gr.Textbox(label="Paste URL Here", placeholder="https://youtube.com/watch?v=...", show_label=False)
243
-
 
 
244
  with gr.TabItem("📂 Upload File (Desktop)"):
245
  input_file = gr.Audio(type="filepath", label="Upload Master Track")
246
 
247
  input_art = gr.Image(type="filepath", label="Cover Art (9:16)")
248
- btn_analyze = gr.Button("🔍 Phase 1: Separate (6 Stems)", variant="primary")
249
-
250
  with gr.Column(scale=1):
251
- gr.Markdown("### 2. Stem Preview")
252
  with gr.Row():
253
  p_drums = gr.Audio(label="Drums")
254
  p_bass = gr.Audio(label="Bass")
@@ -256,34 +844,118 @@ with gr.Blocks(title="Night Pulse | Studio Pro") as app:
256
  p_guitar = gr.Audio(label="Guitar")
257
  p_piano = gr.Audio(label="Piano")
258
  with gr.Row():
259
- p_other = gr.Audio(label="Synths/Other")
260
  p_vocals = gr.Audio(label="Vocals")
261
-
262
  gr.Markdown("---")
263
-
264
  with gr.Row():
265
- with gr.Column():
266
- gr.Markdown("### 3. Loop Logic")
267
- slider_start = gr.Slider(minimum=0, maximum=120, value=15, label="Loop Start Time (Seconds)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  btn_package = gr.Button("📦 Phase 2: Package & Export", variant="primary")
269
-
270
- with gr.Column():
271
- gr.Markdown("### 4. Final Output")
272
  out_zip = gr.File(label="Download Pack (ZIP)")
273
  out_video = gr.Video(label="Promo Video")
274
 
275
  # Events
276
  btn_analyze.click(
277
  fn=analyze_and_separate,
278
- inputs=[input_file, input_url],
279
- outputs=[p_drums, p_bass, p_guitar, p_piano, p_other, p_vocals, stored_bpm, stored_folder]
280
  )
281
-
282
  btn_package.click(
283
  fn=package_and_export,
284
- inputs=[stored_folder, stored_bpm, slider_start, input_art],
285
- outputs=[out_zip, out_video]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  )
287
 
288
  if __name__ == "__main__":
289
- app.launch()
 
5
  import librosa
6
  import numpy as np
7
  from pydub import AudioSegment
8
+ from pydub.silence import split_on_silence
9
  from moviepy.editor import AudioFileClip, ImageClip, CompositeVideoClip
10
  # Keep import available but unused (Full feature set available)
11
  from moviepy.video.fx.all import blackwhite, lum_contrast
 
13
  from pathlib import Path
14
  import sys
15
  import yt_dlp
16
+ import json
17
+ from datetime import datetime
18
 
19
  # --- Configuration ---
20
  OUTPUT_DIR = Path("nightpulse_output")
21
  TEMP_DIR = Path("temp_processing")
22
 
23
+
24
+ # -----------------------------
25
+ # Cloud Import
26
+ # -----------------------------
27
  def download_from_url(url):
28
  """Downloads audio from YouTube/SC/Direct Link to bypass file picker crashes."""
29
+ if not 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"),
36
+ "postprocessors": [
37
+ {"key": "FFmpegExtractAudio", "preferredcodec": "wav", "preferredquality": "192"}
38
+ ],
39
+ "quiet": True,
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)
 
50
  final_path = Path(filename).with_suffix(".wav")
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:
64
+ try:
65
+ y, sr = librosa.load(audio_path, offset=float(offset), duration=float(dur), mono=True)
66
+ if len(y) < sr * 10:
67
+ continue
68
+ tempo, _ = librosa.beat.beat_track(y=y, sr=sr)
69
+ t = float(tempo[0] if np.ndim(tempo) > 0 else tempo)
70
+ if 40 <= t <= 220:
71
+ bpms.append(t)
72
+ except Exception:
73
+ pass
74
+
75
+ if not bpms:
76
+ return None
77
+ return int(round(float(np.median(bpms))))
78
+
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
+
87
+ try:
88
+ tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr, units="frames")
89
+ beat_times = librosa.frames_to_time(beat_frames, sr=sr)
90
+ except Exception:
91
+ beat_times = None
92
+
93
+ if beat_times is None or len(beat_times) < 8:
94
+ ms_per_beat = (60.0 / max(1, int(bpm))) * 1000.0
95
+ total_ms = (len(y) / sr) * 1000.0
96
+ bar_ms = ms_per_beat * 4.0
97
+ return [int(i * bar_ms) for i in range(int(total_ms // bar_ms) + 1)]
98
+
99
+ bar_starts = beat_times[::4]
100
+ return [int(t * 1000.0) for t in bar_starts]
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
123
+ if mode == "peak":
124
+ return seg.normalize()
125
+ if mode == "rms":
126
+ current = rms_dbfs(seg)
127
+ gain = float(target_dbfs) - float(current)
128
+ gain = max(min(gain, 12.0), -12.0)
129
+ return seg.apply_gain(gain)
130
+ return seg
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]
138
+
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:
146
+ return seg
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
+
165
+ selected = []
166
+ used_bars = []
167
+
168
+ for score, start_ms, bar_len in candidates:
169
+ bar_index = int(np.argmin([abs(start_ms - b) for b in bar_starts_ms]))
170
+ if any(abs(bar_index - ub) < int(min_bar_gap) for ub in used_bars):
171
+ continue
172
+ selected.append((score, start_ms, bar_len))
173
+ used_bars.append(bar_index)
174
+
175
+ return selected
176
+
177
+
178
+ # -----------------------------
179
+ # Loop Engine
180
+ # -----------------------------
181
+ def make_quantized_loops(
182
+ stem_path: Path,
183
+ stem_name: str,
184
+ bpm: int,
185
+ bar_starts_ms: list,
186
+ bar_lengths: list,
187
+ hop_bars: int,
188
+ loops_per_stem: int,
189
+ top_k: int,
190
+ fade_ms: int,
191
+ loop_seam: bool,
192
+ seam_ms: int,
193
+ min_bar_gap: int,
194
+ loudness_mode: str,
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
+
202
+ audio = AudioSegment.from_wav(str(stem_path))
203
+ ms_per_beat = (60.0 / max(1, int(bpm))) * 1000.0
204
+ ms_per_bar = int(ms_per_beat * 4.0)
205
+
206
+ hop_bars = max(1, int(hop_bars))
207
+ loops_per_stem = max(1, int(loops_per_stem))
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:
250
+ bar_index = int(np.argmin([abs(start_ms - b) for b in bar_starts_ms]))
251
+ else:
252
+ bar_index = int(start_ms // max(1, ms_per_bar))
253
+
254
+ out_name = f"{int(bpm)}BPM_{stem_name}_B{bar_index:03d}_L{int(bar_len)}bars_R{rank:02d}.wav"
255
+ out_path = out_dir / out_name
256
+ loop.export(out_path, format="wav")
257
+ exported.append(out_path)
258
+
259
+ return exported
260
+
261
+
262
+ # -----------------------------
263
+ # Vocal Chop Engines
264
+ # -----------------------------
265
+ def vocal_chops_silence(
266
+ vocals_path: Path,
267
+ bpm: int,
268
+ out_dir: Path,
269
+ max_chops: int = 48,
270
+ min_len_ms: int = 120,
271
+ max_len_ms: int = 1500,
272
+ silence_thresh_db: int = -35,
273
+ min_silence_len_ms: int = 140,
274
+ keep_silence_ms: int = 20,
275
+ fade_ms: int = 8,
276
+ loudness_mode: str = "none",
277
+ target_dbfs: float = -14.0
278
+ ):
279
+ if not vocals_path.exists():
280
+ return []
281
+
282
+ audio = AudioSegment.from_wav(str(vocals_path))
283
+ chunks = split_on_silence(
284
+ audio,
285
+ min_silence_len=int(min_silence_len_ms),
286
+ silence_thresh=int(silence_thresh_db),
287
+ keep_silence=int(keep_silence_ms),
288
+ )
289
+
290
+ kept = []
291
+ for c in chunks:
292
+ if len(c) < int(min_len_ms):
293
+ continue
294
+ if len(c) > int(max_len_ms):
295
+ c = c[:int(max_len_ms)]
296
+ kept.append(c)
297
+
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)
304
+ if int(fade_ms) > 0:
305
+ c = c.fade_in(int(fade_ms)).fade_out(int(fade_ms))
306
+ c = apply_loudness(c, mode=loudness_mode, target_dbfs=float(target_dbfs))
307
+
308
+ out_name = f"{int(bpm)}BPM_Vocals_CHOP_SIL_R{i:02d}.wav"
309
+ out_path = out_dir / out_name
310
+ c.export(out_path, format="wav")
311
+ exported.append(out_path)
312
+
313
+ return exported
314
+
315
+
316
+ def vocal_chops_onset(
317
+ vocals_path: Path,
318
+ bpm: int,
319
+ out_dir: Path,
320
+ max_chops: int = 48,
321
+ min_len_ms: int = 90,
322
+ max_len_ms: int = 900,
323
+ sr: int = 22050,
324
+ backtrack: bool = True,
325
+ fade_ms: int = 8,
326
+ loudness_mode: str = "none",
327
+ target_dbfs: float = -14.0
328
+ ):
329
+ if not vocals_path.exists():
330
+ return []
331
+
332
+ y, sr = librosa.load(str(vocals_path), sr=sr, mono=True)
333
+ onset_frames = librosa.onset.onset_detect(y=y, sr=sr, backtrack=bool(backtrack))
334
+ onset_times = librosa.frames_to_time(onset_frames, sr=sr)
335
+ onset_ms = [int(t * 1000.0) for t in onset_times]
336
+
337
+ if len(onset_ms) < 3:
338
+ return vocal_chops_silence(
339
+ vocals_path=vocals_path,
340
+ bpm=bpm,
341
+ out_dir=out_dir,
342
+ max_chops=max_chops,
343
+ min_len_ms=min_len_ms,
344
+ max_len_ms=max_len_ms,
345
+ fade_ms=fade_ms,
346
+ loudness_mode=loudness_mode,
347
+ target_dbfs=target_dbfs
348
+ )
349
+
350
+ audio = AudioSegment.from_wav(str(vocals_path))
351
+ segments = []
352
+
353
+ for i in range(len(onset_ms) - 1):
354
+ s = onset_ms[i]
355
+ e = onset_ms[i + 1]
356
+ if e <= s:
357
+ continue
358
+ seg = audio[s:e]
359
+ if len(seg) < int(min_len_ms):
360
+ continue
361
+ if len(seg) > int(max_len_ms):
362
+ seg = seg[:int(max_len_ms)]
363
+ segments.append(seg)
364
+
365
+ tail_start = onset_ms[-1]
366
+ if tail_start < len(audio):
367
+ tail = audio[tail_start: min(len(audio), tail_start + int(max_len_ms))]
368
+ if len(tail) >= int(min_len_ms):
369
+ segments.append(tail)
370
+
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)
377
+ if int(fade_ms) > 0:
378
+ seg = seg.fade_in(int(fade_ms)).fade_out(int(fade_ms))
379
+ seg = apply_loudness(seg, mode=loudness_mode, target_dbfs=float(target_dbfs))
380
+
381
+ out_name = f"{int(bpm)}BPM_Vocals_CHOP_ONS_R{i:02d}.wav"
382
+ out_path = out_dir / out_name
383
+ seg.export(out_path, format="wav")
384
+ exported.append(out_path)
385
+
386
+ return exported
387
+
388
+
389
+ def vocal_chops_grid(
390
+ vocals_path: Path,
391
+ bpm: int,
392
+ out_dir: Path,
393
+ grid_size: str = "1beat",
394
+ max_chops: int = 64,
395
+ fade_ms: int = 6,
396
+ loudness_mode: str = "none",
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
+
407
+ audio = AudioSegment.from_wav(str(vocals_path))
408
+ ms_per_beat = (60.0 / max(1, int(bpm))) * 1000.0
409
+
410
+ grid_map = {
411
+ "half": ms_per_beat * 0.5,
412
+ "1beat": ms_per_beat,
413
+ "2beat": ms_per_beat * 2,
414
+ "1bar": ms_per_beat * 4,
415
+ }
416
+ step = int(grid_map.get((grid_size or "1beat").strip(), ms_per_beat))
417
+
418
+ chops = []
419
+ for start in range(0, len(audio) - step, step):
420
+ seg = audio[start:start + step]
421
+ if seg.rms < int(rms_gate):
422
+ continue
423
+ chops.append((rms_dbfs(seg), seg))
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)
430
+ if int(fade_ms) > 0:
431
+ seg = seg.fade_in(int(fade_ms)).fade_out(int(fade_ms))
432
+ seg = apply_loudness(seg, mode=loudness_mode, target_dbfs=float(target_dbfs))
433
+
434
+ out_name = f"{int(bpm)}BPM_Vocals_CHOP_GRID_{grid_size}_R{i:02d}.wav"
435
+ out_path = out_dir / out_name
436
+ seg.export(out_path, format="wav")
437
+ exported.append(out_path)
438
+
439
+ return exported
440
+
441
+
442
+ # -----------------------------
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":
455
+ return [sys.executable, "-m", "demucs", "-n", "htdemucs", "--two-stems", "vocals", "--out", str(TEMP_DIR), audio_file], "htdemucs"
456
+ if model_mode == "4stem":
457
+ return [sys.executable, "-m", "demucs", "-n", "htdemucs", "--out", str(TEMP_DIR), audio_file], "htdemucs"
458
+
459
+ return [sys.executable, "-m", "demucs", "-n", "htdemucs_6s", "--out", str(TEMP_DIR), audio_file], "htdemucs_6s"
460
+
461
+
462
+ def map_stems(track_folder: Path, mode: str):
463
+ mode = (mode or "6stem").lower().strip()
464
+ stems = {}
465
+
466
+ if mode == "2stem":
467
+ stems["Vocals"] = track_folder / "vocals.wav"
468
+ stems["Instrumental"] = track_folder / "no_vocals.wav"
469
+ return stems
470
+
471
+ if mode == "4stem":
472
+ stems["Drums"] = track_folder / "drums.wav"
473
+ stems["Bass"] = track_folder / "bass.wav"
474
+ stems["Synths"] = track_folder / "other.wav"
475
+ stems["Vocals"] = track_folder / "vocals.wav"
476
+ return stems
477
+
478
+ stems["Drums"] = track_folder / "drums.wav"
479
+ stems["Bass"] = track_folder / "bass.wav"
480
+ stems["Guitar"] = track_folder / "guitar.wav"
481
+ stems["Piano"] = track_folder / "piano.wav"
482
+ stems["Synths"] = track_folder / "other.wav"
483
+ stems["Vocals"] = track_folder / "vocals.wav"
484
+ return stems
485
+
486
+
487
+ # -----------------------------
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:
 
499
  elif file_input:
500
  print("Using File Upload...")
501
  audio_file = file_input
502
+
503
  if not audio_file:
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)
 
 
 
 
 
 
517
  else:
518
+ bpm = detect_bpm_multiwindow(audio_file)
519
+ if bpm is None:
520
+ raise gr.Error("BPM detection failed. Enter BPM manually.")
521
 
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:
534
+ raise FileNotFoundError("Demucs separation failed (no output folder found).")
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
542
+ p_piano = str(stems["Piano"]) if "Piano" in stems and stems["Piano"].exists() else None
543
+
544
+ if "Synths" in stems and stems["Synths"].exists():
545
+ p_other = str(stems["Synths"])
546
+ elif "Instrumental" in stems and stems["Instrumental"].exists():
547
+ p_other = str(stems["Instrumental"])
548
+ else:
549
+ p_other = None
550
+
551
+ p_vocals = str(stems["Vocals"]) if "Vocals" in stems and stems["Vocals"].exists() else None
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
 
561
+
562
+ # -----------------------------
563
+ # Phase 2: Package + Export
564
+ # -----------------------------
565
  def package_and_export(
566
  track_folder_str,
567
  bpm,
568
+ stem_mode,
569
  cover_art,
570
+ export_stems,
571
+ loop_stems,
572
+ enable_vocal_chops,
573
+ loops_per_stem,
574
+ bar_lengths,
575
+ hop_bars,
576
+ top_k,
577
+ fade_ms,
578
+ loop_seam,
579
+ seam_ms,
580
+ min_bar_gap,
581
+ loudness_mode,
582
+ target_dbfs,
583
+ vocal_chop_mode,
584
+ vocal_grid_size,
585
+ vocal_max_chops,
586
+ vocal_min_ms,
587
+ vocal_max_ms,
588
+ vocal_silence_thresh_db,
589
+ vocal_min_silence_len_ms
590
  ):
 
591
  try:
592
  track_folder = Path(track_folder_str)
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)
602
+
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():
615
+ grid_source = stems[k]
616
+ break
617
  if grid_source is None:
618
+ raise FileNotFoundError("No stems found to build bar grid.")
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()}))
626
+ if not bar_lengths_int:
627
+ bar_lengths_int = [4, 8]
628
+
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
636
+ if stem_name not in loop_stems:
637
+ continue
638
+ if not stem_path.exists():
639
+ continue
640
+
641
  exported = make_quantized_loops(
642
  stem_path=stem_path,
643
  stem_name=stem_name,
644
+ bpm=bpm,
645
  bar_starts_ms=bar_starts_ms,
646
+ bar_lengths=bar_lengths_int,
647
  hop_bars=int(hop_bars),
648
+ loops_per_stem=int(loops_per_stem),
649
  top_k=int(top_k),
650
  fade_ms=int(fade_ms),
651
+ loop_seam=bool(loop_seam),
652
+ seam_ms=int(seam_ms),
653
+ min_bar_gap=int(min_bar_gap),
654
  loudness_mode=str(loudness_mode),
655
  target_dbfs=float(target_dbfs),
656
  out_dir=loops_dir
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:
664
+ mode = (vocal_chop_mode or "grid").lower().strip()
665
+ if mode == "silence":
666
+ vocal_exports = vocal_chops_silence(
667
+ vocals_path=stems["Vocals"],
668
+ bpm=bpm,
669
+ out_dir=OUTPUT_DIR / "Vocal_Chops",
670
+ max_chops=int(vocal_max_chops),
671
+ min_len_ms=int(vocal_min_ms),
672
+ max_len_ms=int(vocal_max_ms),
673
+ silence_thresh_db=int(vocal_silence_thresh_db),
674
+ min_silence_len_ms=int(vocal_min_silence_len_ms),
675
+ fade_ms=int(fade_ms),
676
+ loudness_mode=str(loudness_mode),
677
+ target_dbfs=float(target_dbfs),
678
+ )
679
+ elif mode == "onset":
680
+ vocal_exports = vocal_chops_onset(
681
+ vocals_path=stems["Vocals"],
682
+ bpm=bpm,
683
+ out_dir=OUTPUT_DIR / "Vocal_Chops",
684
+ max_chops=int(vocal_max_chops),
685
+ min_len_ms=int(vocal_min_ms),
686
+ max_len_ms=int(vocal_max_ms),
687
+ fade_ms=int(fade_ms),
688
+ loudness_mode=str(loudness_mode),
689
+ target_dbfs=float(target_dbfs),
690
+ )
691
+ elif mode == "grid":
692
+ vocal_exports = vocal_chops_grid(
693
+ vocals_path=stems["Vocals"],
694
+ bpm=bpm,
695
+ out_dir=OUTPUT_DIR / "Vocal_Chops",
696
+ grid_size=str(vocal_grid_size),
697
+ max_chops=int(vocal_max_chops),
698
+ fade_ms=max(1, int(fade_ms // 2)),
699
+ loudness_mode=str(loudness_mode),
700
+ target_dbfs=float(target_dbfs),
701
+ )
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"],
709
+ stem_name="Vocals",
710
+ bpm=bpm,
711
+ bar_starts_ms=bar_starts_ms,
712
+ bar_lengths=bar_lengths_int,
713
+ hop_bars=int(hop_bars),
714
+ loops_per_stem=int(loops_per_stem),
715
+ top_k=int(top_k),
716
+ fade_ms=int(fade_ms),
717
+ loop_seam=bool(loop_seam),
718
+ seam_ms=int(seam_ms),
719
+ min_bar_gap=int(min_bar_gap),
720
+ loudness_mode=str(loudness_mode),
721
+ target_dbfs=float(target_dbfs),
722
+ out_dir=loops_dir
723
+ )
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):
731
+ video_loop = all_loops[key][0]
732
  break
733
 
 
734
  video_path = None
735
  if cover_art and video_loop:
736
  print("Rendering Video...")
 
740
 
741
  img = ImageClip(cover_art).resize(width=1080)
742
  img = img.resize(lambda t: 1 + 0.02 * t)
743
+ img = img.set_position(("center", "center"))
744
  img = img.set_duration(duration)
745
  img = img.set_audio(audio_clip)
746
 
 
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,
759
+ "stem_mode": stem_mode,
760
+ "export_stems": sorted(list(export_stems)),
761
+ "loop_stems": sorted(list(loop_stems)),
762
+ "enable_vocal_chops": bool(enable_vocal_chops),
763
+ "bar_lengths": bar_lengths_int,
764
+ "hop_bars": int(hop_bars),
765
+ "loops_per_stem": int(loops_per_stem),
766
+ "top_k": int(top_k),
767
+ "fade_ms": int(fade_ms),
768
+ "loop_seam": bool(loop_seam),
769
+ "seam_ms": int(seam_ms),
770
+ "min_bar_gap": int(min_bar_gap),
771
+ "loudness_mode": str(loudness_mode),
772
+ "target_dbfs": float(target_dbfs),
773
+ "vocal_chop_mode": str(vocal_chop_mode),
774
+ "vocal_grid_size": str(vocal_grid_size),
775
+ "vocal_max_chops": int(vocal_max_chops),
776
+ "vocal_min_ms": int(vocal_min_ms),
777
+ "vocal_max_ms": int(vocal_max_ms),
778
+ "vocal_silence_thresh_db": int(vocal_silence_thresh_db),
779
+ "vocal_min_silence_len_ms": int(vocal_min_silence_len_ms),
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):
787
  for file in files:
788
  file_path = Path(root) / file
 
795
  raise gr.Error(f"Packaging Failed: {str(e)}")
796
 
797
 
798
+ # -----------------------------
799
+ # UI
800
+ # -----------------------------
801
  with gr.Blocks(title="Night Pulse | Studio Pro") as app:
802
  gr.Markdown("# 🎛️ Night Pulse | Studio Command Center")
803
+ gr.Markdown("Selectable stems + loop engine + real vocal chops + loop-safe seams + variety dedupe.")
804
+
805
  stored_folder = gr.State()
806
  stored_bpm = gr.State()
807
+ stored_mode = gr.State()
808
+
809
  with gr.Row():
810
  with gr.Column(scale=1):
811
  gr.Markdown("### 1. Audio Source")
812
+
813
+ stem_mode = gr.Dropdown(
814
+ choices=[
815
+ ("2 stems (Vocals + Instrumental)", "2stem"),
816
+ ("4 stems (Drums/Bass/Other/Vocals)", "4stem"),
817
+ ("6 stems (Drums/Bass/Guitar/Piano/Other/Vocals)", "6stem"),
818
+ ],
819
+ value="6stem",
820
+ label="Stem Mode"
821
+ )
822
+
823
+ manual_bpm = gr.Number(label="Manual BPM Override (optional)", precision=0, value=None)
824
+
825
  with gr.Tabs():
826
  with gr.TabItem("☁️ Import Link (Mobile Safe)"):
827
+ input_url = gr.Textbox(
828
+ label="Paste URL Here",
829
+ placeholder="https://youtube.com/watch?v=...",
830
+ show_label=False,
831
+ )
832
  with gr.TabItem("📂 Upload File (Desktop)"):
833
  input_file = gr.Audio(type="filepath", label="Upload Master Track")
834
 
835
  input_art = gr.Image(type="filepath", label="Cover Art (9:16)")
836
+ btn_analyze = gr.Button("🔍 Phase 1: Separate Stems", variant="primary")
837
+
838
  with gr.Column(scale=1):
839
+ gr.Markdown("### 2. Stem Preview (missing stems will be blank)")
840
  with gr.Row():
841
  p_drums = gr.Audio(label="Drums")
842
  p_bass = gr.Audio(label="Bass")
 
844
  p_guitar = gr.Audio(label="Guitar")
845
  p_piano = gr.Audio(label="Piano")
846
  with gr.Row():
847
+ p_other = gr.Audio(label="Other / Synths / Instrumental")
848
  p_vocals = gr.Audio(label="Vocals")
849
+
850
  gr.Markdown("---")
851
+
852
  with gr.Row():
853
+ with gr.Column(scale=1):
854
+ gr.Markdown("### 3. Stem Selection")
855
+ export_stems = gr.CheckboxGroup(
856
+ ["Drums", "Bass", "Guitar", "Piano", "Synths", "Vocals", "Instrumental"],
857
+ value=["Drums", "Bass", "Synths", "Vocals"],
858
+ label="Export Full Stems"
859
+ )
860
+
861
+ loop_stems = gr.CheckboxGroup(
862
+ ["Drums", "Bass", "Guitar", "Piano", "Synths", "Instrumental", "Vocals"],
863
+ value=["Drums", "Bass", "Synths"],
864
+ label="Generate Loops For"
865
+ )
866
+
867
+ enable_vocal_chops = gr.Checkbox(value=True, label="Generate Vocal Chops (vocals only)")
868
+
869
+ gr.Markdown("### 4. Loop Engine Settings")
870
+ loops_per_stem = gr.Slider(1, 40, value=12, step=1, label="Loops per Stem (selected loop stems)")
871
+ bar_lengths = gr.CheckboxGroup(
872
+ choices=["1", "2", "4", "8"],
873
+ value=["4", "8"],
874
+ label="Bar Lengths (4/4)"
875
+ )
876
+ hop_bars = gr.Slider(1, 8, value=1, step=1, label="Hop (bars between starts)")
877
+ top_k = gr.Slider(0, 200, value=30, step=1, label="Top-K candidates per stem (0 = no filter)")
878
+ min_bar_gap = gr.Slider(0, 16, value=4, step=1, label="Min bar gap (de-dup spacing)")
879
+
880
+ fade_ms = gr.Slider(0, 50, value=12, step=1, label="Click-safety fade (ms)")
881
+ loop_seam = gr.Checkbox(value=True, label="Loop-safe seam (crossfade ends)")
882
+ seam_ms = gr.Slider(0, 80, value=20, step=1, label="Loop seam crossfade (ms)")
883
+
884
+ loudness_mode = gr.Dropdown(
885
+ choices=["none", "peak", "rms"],
886
+ value="none",
887
+ label="Loudness mode"
888
+ )
889
+ target_dbfs = gr.Slider(-24, -8, value=-14, step=1, label="Target RMS dBFS (only for rms mode)")
890
+
891
+ gr.Markdown("### 5. Vocals: Real Chop Mode")
892
+ vocal_chop_mode = gr.Dropdown(
893
+ choices=[("Silence chops", "silence"),
894
+ ("Onset chops", "onset"),
895
+ ("Grid chops (BPM)", "grid")],
896
+ value="grid",
897
+ label="Vocal Chop Mode"
898
+ )
899
+
900
+ vocal_grid_size = gr.Dropdown(
901
+ choices=[("Half beat", "half"),
902
+ ("1 beat", "1beat"),
903
+ ("2 beats", "2beat"),
904
+ ("1 bar", "1bar")],
905
+ value="1beat",
906
+ label="Grid Chop Size"
907
+ )
908
+
909
+ vocal_max_chops = gr.Slider(4, 160, value=64, step=1, label="Max vocal chops to export")
910
+ vocal_min_ms = gr.Slider(40, 500, value=120, step=10, label="Min chop length (ms)")
911
+ vocal_max_ms = gr.Slider(200, 4000, value=1500, step=50, label="Max chop length (ms)")
912
+ vocal_silence_thresh_db = gr.Slider(-60, -10, value=-35, step=1, label="Silence threshold (dBFS, silence mode)")
913
+ vocal_min_silence_len_ms = gr.Slider(60, 800, value=140, step=10, label="Min silence length (ms, silence mode)")
914
+
915
  btn_package = gr.Button("📦 Phase 2: Package & Export", variant="primary")
916
+
917
+ with gr.Column(scale=1):
918
+ gr.Markdown("### 6. Final Output")
919
  out_zip = gr.File(label="Download Pack (ZIP)")
920
  out_video = gr.Video(label="Promo Video")
921
 
922
  # Events
923
  btn_analyze.click(
924
  fn=analyze_and_separate,
925
+ inputs=[input_file, input_url, stem_mode, manual_bpm],
926
+ outputs=[p_drums, p_bass, p_guitar, p_piano, p_other, p_vocals, stored_bpm, stored_folder, stored_mode],
927
  )
928
+
929
  btn_package.click(
930
  fn=package_and_export,
931
+ inputs=[
932
+ stored_folder,
933
+ stored_bpm,
934
+ stored_mode,
935
+ input_art,
936
+ export_stems,
937
+ loop_stems,
938
+ enable_vocal_chops,
939
+ loops_per_stem,
940
+ bar_lengths,
941
+ hop_bars,
942
+ top_k,
943
+ fade_ms,
944
+ loop_seam,
945
+ seam_ms,
946
+ min_bar_gap,
947
+ loudness_mode,
948
+ target_dbfs,
949
+ vocal_chop_mode,
950
+ vocal_grid_size,
951
+ vocal_max_chops,
952
+ vocal_min_ms,
953
+ vocal_max_ms,
954
+ vocal_silence_thresh_db,
955
+ vocal_min_silence_len_ms,
956
+ ],
957
+ outputs=[out_zip, out_video],
958
  )
959
 
960
  if __name__ == "__main__":
961
+ app.launch()