SaltProphet commited on
Commit
1e72884
·
verified ·
1 Parent(s): 8b9b3f3

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +307 -158
app.py CHANGED
@@ -3,63 +3,81 @@ import os
3
  import shutil
4
  import asyncio
5
  import librosa
6
- import librosa.display
7
  import soundfile as sf
8
  import numpy as np
9
- import time
10
  import zipfile
11
  import tempfile
12
- import matplotlib.pyplot as plt
13
  import matplotlib
14
  matplotlib.use('Agg')
15
 
 
 
 
 
16
  def update_output_visibility(choice):
17
  if "2 Stems" in choice:
18
- return {
19
- vocals_output: gr.update(visible=True),
20
- drums_output: gr.update(visible=False),
21
- bass_output: gr.update(visible=False),
22
- other_output: gr.update(visible=True, label="Instrumental (No Vocals)")
23
- }
24
- elif "4 Stems" in choice:
25
- return {
26
- vocals_output: gr.update(visible=True),
27
- drums_output: gr.update(visible=True),
28
- bass_output: gr.update(visible=True),
29
- other_output: gr.update(visible=True, label="Other")
30
- }
 
 
 
 
31
 
32
  async def separate_stems(audio_file_path, stem_choice, progress=gr.Progress(track_tqdm=True)):
33
- if audio_file_path is None: raise gr.Error("No audio file uploaded!")
 
 
34
  log_history = "Starting separation...\n"
35
- yield { status_log: log_history, progress_bar: progress(0, desc="Starting...", visible=True) }
 
36
  try:
37
  progress(0.05, desc="Preparing audio file...")
38
- log_history += "Preparing audio file...\n"; yield { status_log: log_history, progress_bar: progress(0.05, desc="Preparing...") }
 
 
39
  original_filename_base = os.path.basename(audio_file_path).rsplit('.', 1)[0]
40
  stable_input_path = f"stable_input_{original_filename_base}.wav"
 
41
  shutil.copy(audio_file_path, stable_input_path)
42
 
43
  model_arg = "--two-stems=vocals" if "2 Stems" in stem_choice else ""
44
  output_dir = "separated"
45
- if os.path.exists(output_dir): shutil.rmtree(output_dir)
 
46
 
47
- command = f"python3 -m demucs {model_arg} -o \"{output_dir}\" \"{stable_input_path}\""
48
- log_history += f"Running Demucs command: {command}\n(This may take a minute or more depending on track length)\n";
49
- yield { status_log: log_history, progress_bar: progress(0.2, desc="Running Demucs...") }
 
50
 
 
51
  process = await asyncio.create_subprocess_shell(
52
- command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
 
53
  stdout, stderr = await process.communicate()
54
 
55
  if process.returncode != 0:
56
- raise gr.Error(f"Demucs failed. Error: {stderr.decode()[:500]}")
57
 
58
- log_history += "Demucs finished. Locating stem files...\n"; yield { status_log: log_history, progress_bar: progress(0.8, desc="Locating stems...") }
59
- stable_filename_base = os.path.basename(stable_input_path).rsplit('.', 1)[0]
 
60
 
 
61
  subfolders = [f.name for f in os.scandir(output_dir) if f.is_dir()]
62
- if not subfolders: raise gr.Error("Demucs output folder structure not found!")
 
63
  model_folder_name = subfolders[0]
64
  stems_path = os.path.join(output_dir, model_folder_name, stable_filename_base)
65
 
@@ -67,197 +85,328 @@ async def separate_stems(audio_file_path, stem_choice, progress=gr.Progress(trac
67
  raise gr.Error(f"Demucs output directory was not found! Looked for: {stems_path}")
68
 
69
  vocals_path = os.path.join(stems_path, "vocals.wav") if os.path.exists(os.path.join(stems_path, "vocals.wav")) else None
70
- drums_path = os.path.join(stems_path, "drums.wav") if os.path.exists(os.path.join(stems_path, "drums.wav")) else None
71
- bass_path = os.path.join(stems_path, "bass.wav") if os.path.exists(os.path.join(stems_path, "bass.wav")) else None
72
- other_filename = "no_vocals.wav" if "2 Stems" in stem_choice else "other.wav"
73
- other_path = os.path.join(stems_path, other_filename) if os.path.exists(os.path.join(stems_path, other_filename)) else None
74
-
75
- os.remove(stable_input_path)
76
-
77
- log_history += "✅ Stem separation complete!\n";
78
- yield {
79
- status_log: log_history,
80
- progress_bar: progress(1, desc="Complete!", visible=False), # Hide progress bar when done
81
- vocals_output: gr.update(value=vocals_path),
82
- drums_output: gr.update(value=drums_path),
83
- bass_output: gr.update(value=bass_path),
84
- other_output: gr.update(value=other_path)
85
- }
 
 
 
86
  except Exception as e:
87
- print(f"An error occurred during separation: {e}")
88
- yield { status_log: log_history + f" ERROR: {e}", progress_bar: gr.update(visible=False) } # Hide progress bar on error
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
- def slice_stem_real(stem_audio_data, loop_choice, sensitivity, stem_name, progress_fn=None):
91
- if stem_audio_data is None: return None, None
92
- sample_rate, y_int = stem_audio_data; y = librosa.util.buf_to_float(y_int)
93
- y_mono = librosa.to_mono(y.T) if y.ndim > 1 else y
94
  if progress_fn: progress_fn(0.1, desc="Detecting BPM...")
95
- tempo, beats = librosa.beat.beat_track(y=y_mono, sr=sample_rate)
96
- bpm = 120 if tempo is None else np.round(tempo); bpm_int = int(bpm.item())
97
- if bpm_int == 0: bpm_int = 120; print("BPM detection failed, defaulting to 120 BPM.")
98
- print(f"Detected BPM for {stem_name}: {bpm_int}")
99
- output_files = []; loops_dir = tempfile.mkdtemp()
 
 
 
 
100
  if "One-Shots" in loop_choice:
101
  if progress_fn: progress_fn(0.3, desc="Finding transients...")
102
- onset_frames = librosa.onset.onset_detect(y=y_mono, sr=sample_rate, delta=sensitivity, wait=1, pre_avg=1, post_avg=1, post_max=1)
 
 
103
  onset_samples = librosa.frames_to_samples(onset_frames)
 
 
 
 
 
 
 
 
 
 
104
  if progress_fn: progress_fn(0.5, desc="Slicing one-shots...")
 
105
  if len(onset_samples) > 0:
106
  num_onsets = len(onset_samples)
 
107
  for i, start_sample in enumerate(onset_samples):
108
- end_sample = onset_samples[i+1] if i+1 < num_onsets else len(y)
109
- slice_data = y[start_sample:end_sample]
 
 
110
  filename = os.path.join(loops_dir, f"{stem_name}_one_shot_{i+1:03d}.wav")
111
- sf.write(filename, slice_data, sample_rate, subtype='PCM_16')
112
  output_files.append(filename)
113
  if progress_fn and num_onsets > 1:
114
  progress_fn(0.5 + (i / (num_onsets - 1) * 0.5), desc=f"Exporting slice {i+1}/{num_onsets}...")
115
  else:
116
- bars = int(loop_choice.split(" ")[0]); seconds_per_beat = 60.0 / bpm_int; seconds_per_bar = seconds_per_beat * 4
117
- loop_duration_seconds = seconds_per_bar * bars; loop_duration_samples = int(loop_duration_seconds * sample_rate)
118
- if progress_fn: progress_fn(0.4, desc=f"Slicing into {bars}-bar loops...")
119
- num_loops = len(y) // loop_duration_samples
120
- if num_loops == 0: print(f"Audio for {stem_name} is too short for {bars}-bar loops at {bpm_int} BPM."); return None, None
 
 
 
 
 
121
  for i in range(num_loops):
122
- start_sample = i * loop_duration_samples; end_sample = start_sample + loop_duration_samples
123
- slice_data = y[start_sample:end_sample]
124
- filename = os.path.join(loops_dir, f"{stem_name}_{bars}bar_loop_{i+1:03d}_{bpm_int}bpm.wav")
125
- sf.write(filename, slice_data, sample_rate, subtype='PCM_16')
 
126
  output_files.append(filename)
127
  if progress_fn and num_loops > 1:
128
  progress_fn(0.4 + (i / (num_loops - 1) * 0.6), desc=f"Exporting loop {i+1}/{num_loops}...")
129
- if not output_files: return None, None
 
 
130
  return output_files, loops_dir
131
 
132
- async def slice_all_and_zip_real(vocals, drums, bass, other, loop_choice, sensitivity, progress=gr.Progress(track_tqdm=True)):
 
 
 
 
 
 
 
133
  log_history = "Starting batch slice...\n"
134
- yield { status_log: log_history, progress_bar: progress(0, desc="Starting...", visible=True) }
135
- await asyncio.sleep(0.1)
 
136
  stems_to_process = {"vocals": vocals, "drums": drums, "bass": bass, "other": other}
 
 
 
 
137
  zip_path = "Loop_Architect_Pack.zip"
138
- num_stems = sum(1 for data in stems_to_process.values() if data is not None)
139
- if num_stems == 0: raise gr.Error("No stems to process! Please separate stems first.")
140
  all_temp_dirs = []
141
  try:
142
  with zipfile.ZipFile(zip_path, 'w') as zf:
143
- processed_count = 0
144
- for name, data in stems_to_process.items():
145
- if data is not None:
146
- log_history += f"--- Slicing {name} stem ---\n"; yield { status_log: log_history }
147
- def update_main_progress(p, desc=""):
148
- overall_progress = (processed_count + p) / num_stems
149
- progress(overall_progress, desc=f"Slicing {name}: {desc}", visible=True)
150
-
151
- sliced_files, temp_dir = slice_stem_real((data[0], data[1]), loop_choice, sensitivity, name, progress_fn=update_main_progress)
152
-
153
- if sliced_files:
154
- log_history += f"Generated {len(sliced_files)} slices for {name}.\n"; yield { status_log: log_history }
155
- all_temp_dirs.append(temp_dir)
156
- for loop_file in sliced_files:
157
- arcname = os.path.join(name, os.path.basename(loop_file))
158
- zf.write(loop_file, arcname)
159
- else:
160
- log_history += f"No slices generated for {name}.\n"; yield { status_log: log_history }
161
-
162
- processed_count += 1
163
- yield { status_log: log_history, progress_bar: progress(processed_count / num_stems, desc=f"Finished {name}", visible=True) }
164
-
165
- log_history += "Packaging complete!\n";
166
- yield {
167
- status_log: log_history + "✅ Pack ready for download!",
168
- progress_bar: progress(1, desc="Pack Ready!", visible=False),
169
- download_zip_file: gr.update(value=zip_path, visible=True)
170
- }
 
 
171
  except Exception as e:
172
- print(f"An error occurred during slice all: {e}")
173
- yield { status_log: log_history + f" ERROR: {e}", progress_bar: gr.update(visible=False) }
 
174
  finally:
175
  for d in all_temp_dirs:
176
  if d and os.path.exists(d):
177
  shutil.rmtree(d)
178
 
179
- # --- Create the full Gradio Interface ---
 
 
 
180
  with gr.Blocks(theme=gr.themes.Default(primary_hue="blue", secondary_hue="red")) as demo:
181
- gr.Markdown("# 🎵 Loop Architect")
 
182
  with gr.Row():
183
  with gr.Column(scale=1):
184
- gr.Markdown("### 1. Separate Stems")
185
  audio_input = gr.Audio(type="filepath", label="Upload a Track")
186
- stem_options = gr.Radio(["4 Stems (Vocals, Drums, Bass, Other)", "2 Stems (Vocals + Instrumental)"], label="Separation Type", value="4 Stems (Vocals, Drums, Bass, Other)")
187
- submit_button = gr.Button("Separate Stems")
188
-
189
- with gr.Accordion("Slicing Options", open=True):
190
- loop_options_radio = gr.Radio(["One-Shots (All Transients)", "4 Bar Loops", "8 Bar Loops"], label="Slice Type", value="One-Shots (All Transients)")
191
- sensitivity_slider = gr.Slider(minimum=0.01, maximum=0.5, value=0.05, step=0.01, label="One-Shot Sensitivity", info="Lower values = more slices")
192
-
193
- gr.Markdown("### 3. Create Pack")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  slice_all_button = gr.Button("Slice All Stems & Create Pack", variant="primary")
195
  download_zip_file = gr.File(label="Download Your Loop Pack", visible=False)
196
 
197
  gr.Markdown("### Status")
198
- # --- THIS IS THE FIX --- Initialize without any arguments
199
- progress_bar = gr.Progress()
200
- status_log = gr.Textbox(label="Status Log", lines=10, interactive=False)
201
 
202
  with gr.Column(scale=2):
203
  with gr.Accordion("Separated Stems", open=True):
204
  with gr.Row():
205
- vocals_output = gr.Audio(label="Vocals", scale=4)
206
  slice_vocals_btn = gr.Button("Slice Vocals", scale=1)
207
  with gr.Row():
208
- drums_output = gr.Audio(label="Drums", scale=4)
209
  slice_drums_btn = gr.Button("Slice Drums", scale=1)
210
  with gr.Row():
211
- bass_output = gr.Audio(label="Bass", scale=4)
212
  slice_bass_btn = gr.Button("Slice Bass", scale=1)
213
  with gr.Row():
214
- other_output = gr.Audio(label="Other / Instrumental", scale=4)
215
  slice_other_btn = gr.Button("Slice Other", scale=1)
216
- gr.Markdown("### Sliced Loops / Samples (Preview)")
217
- loop_gallery = gr.Gallery(label="Generated Loops Preview", columns=8, object_fit="contain", height="auto", preview=True)
218
 
219
- # --- Define Event Listeners ---
220
- def slice_and_display(stem_data, loop_choice, sensitivity, stem_name):
221
- log_history = f"Slicing {stem_name}...\n"
222
- # Make progress bar visible when starting
223
- yield {
224
- status_log: log_history,
225
- progress_bar: gr.update(value=0, visible=True, label=f"Slicing {stem_name}...")
226
- }
227
-
228
- # Define how slice_stem_real updates the progress bar using gr.Progress directly
229
- def update_single_progress(p, desc=""):
230
- gr.Progress(track_tqdm=True)(p, desc=desc) # Pass progress updates
231
-
232
- files, temp_dir = slice_stem_real(stem_data, loop_choice, sensitivity, stem_name, progress_fn=update_single_progress)
233
-
234
- if temp_dir and os.path.exists(temp_dir):
235
- shutil.rmtree(temp_dir)
236
 
237
- yield {
238
- loop_gallery: gr.update(value=files),
239
- status_log: log_history + f"✅ Sliced {stem_name} into {len(files) if files else 0} pieces.",
240
- progress_bar: gr.update(visible=False) # Hide progress bar when done
241
- }
242
-
243
- submit_event = submit_button.click(
244
  fn=separate_stems,
245
  inputs=[audio_input, stem_options],
246
  outputs=[vocals_output, drums_output, bass_output, other_output, status_log]
247
  )
248
 
249
- stem_options.change(fn=update_output_visibility, inputs=stem_options, outputs=[vocals_output, drums_output, bass_output, other_output])
 
 
 
 
250
 
251
- slice_vocals_btn.click(fn=slice_and_display, inputs=[vocals_output, loop_options_radio, sensitivity_slider, gr.Textbox("vocals", visible=False)], outputs=[loop_gallery, status_log, progress_bar])
252
- slice_drums_btn.click(fn=slice_and_display, inputs=[drums_output, loop_options_radio, sensitivity_slider, gr.Textbox("drums", visible=False)], outputs=[loop_gallery, status_log, progress_bar])
253
- slice_bass_btn.click(fn=slice_and_display, inputs=[bass_output, loop_options_radio, sensitivity_slider, gr.Textbox("bass", visible=False)], outputs=[loop_gallery, status_log, progress_bar])
254
- slice_other_btn.click(fn=slice_and_display, inputs=[other_output, loop_options_radio, sensitivity_slider, gr.Textbox("other", visible=False)], outputs=[loop_gallery, status_log, progress_bar])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
 
256
- slice_all_event = slice_all_button.click(
257
  fn=slice_all_and_zip_real,
258
- inputs=[vocals_output, drums_output, bass_output, other_output, loop_options_radio, sensitivity_slider],
259
- outputs=[download_zip_file, download_zip_file, status_log]
 
 
260
  )
261
 
262
- # --- Launch the UI ---
263
- demo.launch()
 
3
  import shutil
4
  import asyncio
5
  import librosa
 
6
  import soundfile as sf
7
  import numpy as np
 
8
  import zipfile
9
  import tempfile
 
10
  import matplotlib
11
  matplotlib.use('Agg')
12
 
13
+ # ---------------------------
14
+ # UI helpers
15
+ # ---------------------------
16
+
17
  def update_output_visibility(choice):
18
  if "2 Stems" in choice:
19
+ return (
20
+ gr.update(visible=True),
21
+ gr.update(visible=False),
22
+ gr.update(visible=False),
23
+ gr.update(visible=True, label="Instrumental (No Vocals)")
24
+ )
25
+ else: # 4 stems
26
+ return (
27
+ gr.update(visible=True),
28
+ gr.update(visible=True),
29
+ gr.update(visible=True),
30
+ gr.update(visible=True, label="Other")
31
+ )
32
+
33
+ # ---------------------------
34
+ # Stem separation (Demucs)
35
+ # ---------------------------
36
 
37
  async def separate_stems(audio_file_path, stem_choice, progress=gr.Progress(track_tqdm=True)):
38
+ # outputs: [vocals_out, drums_out, bass_out, other_out, status_log]
39
+ if audio_file_path is None:
40
+ raise gr.Error("No audio file uploaded!")
41
  log_history = "Starting separation...\n"
42
+ yield (gr.update(), gr.update(), gr.update(), gr.update(), log_history)
43
+
44
  try:
45
  progress(0.05, desc="Preparing audio file...")
46
+ log_history += "Preparing audio file...\n"
47
+ yield (gr.update(), gr.update(), gr.update(), gr.update(), log_history)
48
+
49
  original_filename_base = os.path.basename(audio_file_path).rsplit('.', 1)[0]
50
  stable_input_path = f"stable_input_{original_filename_base}.wav"
51
+ # Ensure a .wav input for Demucs (demucs can read many formats, but filepath stability helps)
52
  shutil.copy(audio_file_path, stable_input_path)
53
 
54
  model_arg = "--two-stems=vocals" if "2 Stems" in stem_choice else ""
55
  output_dir = "separated"
56
+ if os.path.exists(output_dir):
57
+ shutil.rmtree(output_dir)
58
 
59
+ command = f"python -m demucs {model_arg} -o \"{output_dir}\" \"{stable_input_path}\""
60
+ log_history += f"Running Demucs command: {command}\n"
61
+ log_history += "(This may take a minute or more depending on track length)\n"
62
+ yield (gr.update(), gr.update(), gr.update(), gr.update(), log_history)
63
 
64
+ progress(0.2, desc="Running Demucs...")
65
  process = await asyncio.create_subprocess_shell(
66
+ command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
67
+ )
68
  stdout, stderr = await process.communicate()
69
 
70
  if process.returncode != 0:
71
+ raise gr.Error(f"Demucs failed. Error: {stderr.decode(errors='ignore')[:800]}")
72
 
73
+ log_history += "Demucs finished. Locating stem files...\n"
74
+ progress(0.8, desc="Locating stems...")
75
+ yield (gr.update(), gr.update(), gr.update(), gr.update(), log_history)
76
 
77
+ stable_filename_base = os.path.basename(stable_input_path).rsplit('.', 1)[0]
78
  subfolders = [f.name for f in os.scandir(output_dir) if f.is_dir()]
79
+ if not subfolders:
80
+ raise gr.Error("Demucs output folder structure not found!")
81
  model_folder_name = subfolders[0]
82
  stems_path = os.path.join(output_dir, model_folder_name, stable_filename_base)
83
 
 
85
  raise gr.Error(f"Demucs output directory was not found! Looked for: {stems_path}")
86
 
87
  vocals_path = os.path.join(stems_path, "vocals.wav") if os.path.exists(os.path.join(stems_path, "vocals.wav")) else None
88
+ drums_path = os.path.join(stems_path, "drums.wav") if os.path.exists(os.path.join(stems_path, "drums.wav")) else None
89
+ bass_path = os.path.join(stems_path, "bass.wav") if os.path.exists(os.path.join(stems_path, "bass.wav")) else None
90
+ other_name = "no_vocals.wav" if "2 Stems" in stem_choice else "other.wav"
91
+ other_path = os.path.join(stems_path, other_name) if os.path.exists(os.path.join(stems_path, other_name)) else None
92
+
93
+ try:
94
+ os.remove(stable_input_path)
95
+ except Exception:
96
+ pass
97
+
98
+ log_history += " Stem separation complete!\n"
99
+ yield (
100
+ gr.update(value=vocals_path),
101
+ gr.update(value=drums_path),
102
+ gr.update(value=bass_path),
103
+ gr.update(value=other_path),
104
+ log_history
105
+ )
106
+
107
  except Exception as e:
108
+ err = f" ERROR: {e}"
109
+ print(f"Separation error: {e}")
110
+ yield (gr.update(), gr.update(), gr.update(), gr.update(), log_history + err)
111
+
112
+ # ---------------------------
113
+ # Slicing (BPM override + Quantized grid)
114
+ # ---------------------------
115
+
116
+ def _grid_beats_per_step(grid_label: str) -> float:
117
+ # "1/16 (Sixteenth)" -> "1/16"
118
+ tok = grid_label.split(" ")[0].strip()
119
+ mapping = {"1/1": 4.0, "1/2": 2.0, "1/4": 1.0, "1/8": 0.5, "1/16": 0.25}
120
+ return mapping.get(tok, 0.25)
121
+
122
+ def _load_audio_any(stem_input):
123
+ """
124
+ Accepts a filepath string OR (sr, numpy_array) and returns (sr, mono_float_array).
125
+ We use filepath everywhere in this app to keep memory stable.
126
+ """
127
+ if stem_input is None:
128
+ return None, None
129
+ if isinstance(stem_input, str):
130
+ y, sr = librosa.load(stem_input, sr=None, mono=False)
131
+ else:
132
+ sr, y = stem_input
133
+ y = librosa.util.buf_to_float(y)
134
+ y_mono = librosa.to_mono(y) if y.ndim > 1 else y
135
+ return sr, y_mono
136
+
137
+ def slice_stem_real(
138
+ stem_input,
139
+ loop_choice,
140
+ sensitivity,
141
+ stem_name,
142
+ progress_fn=None,
143
+ bpm_override_val: int = 0,
144
+ quantize: bool = True,
145
+ grid_label: str = "1/16 (Sixteenth)",
146
+ ):
147
+ sr, y_mono = _load_audio_any(stem_input)
148
+ if sr is None or y_mono is None:
149
+ return None, None
150
 
 
 
 
 
151
  if progress_fn: progress_fn(0.1, desc="Detecting BPM...")
152
+ tempo, _ = librosa.beat.beat_track(y=y_mono, sr=sr)
153
+ det_bpm = int(np.round(tempo)) if tempo and tempo > 0 else 120
154
+ bpm = int(bpm_override_val) if bpm_override_val and bpm_override_val > 0 else det_bpm
155
+ if bpm <= 0:
156
+ bpm = 120
157
+
158
+ output_files = []
159
+ loops_dir = tempfile.mkdtemp()
160
+
161
  if "One-Shots" in loop_choice:
162
  if progress_fn: progress_fn(0.3, desc="Finding transients...")
163
+ onset_frames = librosa.onset.onset_detect(
164
+ y=y_mono, sr=sr, delta=sensitivity, wait=1, pre_avg=1, post_avg=1, post_max=1
165
+ )
166
  onset_samples = librosa.frames_to_samples(onset_frames)
167
+
168
+ # Quantize onsets to musical grid if enabled
169
+ if quantize and len(onset_samples) > 0:
170
+ beats_per_step = _grid_beats_per_step(grid_label)
171
+ samples_per_beat = sr * (60.0 / bpm)
172
+ step = max(1, int(round(samples_per_beat * beats_per_step)))
173
+ q = np.clip(np.round(onset_samples / step) * step, 0, len(y_mono) - 1).astype(int)
174
+ q = np.unique(q) # dedupe after snapping
175
+ onset_samples = q
176
+
177
  if progress_fn: progress_fn(0.5, desc="Slicing one-shots...")
178
+
179
  if len(onset_samples) > 0:
180
  num_onsets = len(onset_samples)
181
+ min_len = max(1, int(0.02 * sr)) # 20ms min slice guard
182
  for i, start_sample in enumerate(onset_samples):
183
+ end_sample = onset_samples[i+1] if i+1 < num_onsets else len(y_mono)
184
+ if end_sample - start_sample < min_len:
185
+ continue
186
+ slice_data = y_mono[start_sample:end_sample]
187
  filename = os.path.join(loops_dir, f"{stem_name}_one_shot_{i+1:03d}.wav")
188
+ sf.write(filename, slice_data, sr, subtype='PCM_16')
189
  output_files.append(filename)
190
  if progress_fn and num_onsets > 1:
191
  progress_fn(0.5 + (i / (num_onsets - 1) * 0.5), desc=f"Exporting slice {i+1}/{num_onsets}...")
192
  else:
193
+ # Grid-true by construction; honor BPM override
194
+ bars = int(loop_choice.split(" ")[0])
195
+ seconds_per_beat = 60.0 / bpm
196
+ seconds_per_bar = seconds_per_beat * 4
197
+ loop_duration_seconds = seconds_per_bar * bars
198
+ loop_duration_samples = int(loop_duration_seconds * sr)
199
+ if progress_fn: progress_fn(0.4, desc=f"Slicing into {bars}-bar loops @ {bpm} BPM...")
200
+ num_loops = len(y_mono) // loop_duration_samples
201
+ if num_loops == 0:
202
+ return None, None
203
  for i in range(num_loops):
204
+ start_sample = i * loop_duration_samples
205
+ end_sample = start_sample + loop_duration_samples
206
+ slice_data = y_mono[start_sample:end_sample]
207
+ filename = os.path.join(loops_dir, f"{stem_name}_{bars}bar_loop_{i+1:03d}_{bpm}bpm.wav")
208
+ sf.write(filename, slice_data, sr, subtype='PCM_16')
209
  output_files.append(filename)
210
  if progress_fn and num_loops > 1:
211
  progress_fn(0.4 + (i / (num_loops - 1) * 0.6), desc=f"Exporting loop {i+1}/{num_loops}...")
212
+
213
+ if not output_files:
214
+ return None, None
215
  return output_files, loops_dir
216
 
217
+ # ---------------------------
218
+ # Batch wrapper
219
+ # ---------------------------
220
+
221
+ async def slice_all_and_zip_real(vocals, drums, bass, other, loop_choice, sensitivity,
222
+ bpm_over, quantize_on, grid_sel,
223
+ progress=gr.Progress(track_tqdm=True)):
224
+ # outputs: [status_log, download_zip_file]
225
  log_history = "Starting batch slice...\n"
226
+ yield (log_history, gr.update(visible=False))
227
+
228
+ await asyncio.sleep(0.05)
229
  stems_to_process = {"vocals": vocals, "drums": drums, "bass": bass, "other": other}
230
+ present = {k: v for k, v in stems_to_process.items() if v}
231
+ if not present:
232
+ raise gr.Error("No stems to process! Please separate stems first.")
233
+
234
  zip_path = "Loop_Architect_Pack.zip"
 
 
235
  all_temp_dirs = []
236
  try:
237
  with zipfile.ZipFile(zip_path, 'w') as zf:
238
+ processed, total = 0, len(present)
239
+ for name, data in present.items():
240
+ log_history += f"--- Slicing {name} stem ---\n"
241
+ yield (log_history, gr.update(visible=False))
242
+
243
+ def update_main_progress(p, desc=""):
244
+ progress((processed + max(0.01, p)) / total, desc=f"{name}: {desc}")
245
+
246
+ sliced_files, temp_dir = slice_stem_real(
247
+ data, loop_choice, sensitivity, name,
248
+ progress_fn=update_main_progress,
249
+ bpm_override_val=int(bpm_over),
250
+ quantize=bool(quantize_on),
251
+ grid_label=grid_sel
252
+ )
253
+ if temp_dir:
254
+ all_temp_dirs.append(temp_dir)
255
+ if sliced_files:
256
+ log_history += f"Generated {len(sliced_files)} slices for {name}.\n"
257
+ for loop_file in sliced_files:
258
+ zf.write(loop_file, os.path.join(name, os.path.basename(loop_file)))
259
+ else:
260
+ log_history += f"No slices generated for {name}.\n"
261
+
262
+ processed += 1
263
+ yield (log_history, gr.update(visible=False))
264
+
265
+ log_history += "Packaging complete! ✅ Pack ready for download.\n"
266
+ yield (log_history, gr.update(value=zip_path, visible=True))
267
+
268
  except Exception as e:
269
+ err = f" ERROR: {e}"
270
+ print(f"Batch slice error: {e}")
271
+ yield (log_history + err, gr.update(visible=False))
272
  finally:
273
  for d in all_temp_dirs:
274
  if d and os.path.exists(d):
275
  shutil.rmtree(d)
276
 
277
+ # ---------------------------
278
+ # Gradio UI
279
+ # ---------------------------
280
+
281
  with gr.Blocks(theme=gr.themes.Default(primary_hue="blue", secondary_hue="red")) as demo:
282
+ gr.Markdown("# 🎵 Loop Architect — SALT PRØPHET Edition")
283
+
284
  with gr.Row():
285
  with gr.Column(scale=1):
286
+ gr.Markdown("### 1) Separate Stems")
287
  audio_input = gr.Audio(type="filepath", label="Upload a Track")
288
+ stem_options = gr.Radio(
289
+ ["4 Stems (Vocals, Drums, Bass, Other)", "2 Stems (Vocals + Instrumental)"],
290
+ label="Separation Type",
291
+ value="4 Stems (Vocals, Drums, Bass, Other)"
292
+ )
293
+ submit_button = gr.Button("Separate Stems", variant="secondary")
294
+
295
+ with gr.Accordion("2) Slicing Options", open=True):
296
+ loop_options_radio = gr.Radio(
297
+ ["One-Shots (All Transients)", "4 Bar Loops", "8 Bar Loops"],
298
+ label="Slice Type",
299
+ value="One-Shots (All Transients)"
300
+ )
301
+ sensitivity_slider = gr.Slider(
302
+ minimum=0.01, maximum=0.5, value=0.05, step=0.01,
303
+ label="One-Shot Sensitivity", info="Lower values = more slices"
304
+ )
305
+ bpm_override = gr.Slider(
306
+ minimum=0, maximum=240, value=0, step=1,
307
+ label="BPM Override (0 = auto-detect)"
308
+ )
309
+ quantize_toggle = gr.Checkbox(
310
+ label="Quantize One-Shots to Grid", value=True
311
+ )
312
+ grid_select = gr.Dropdown(
313
+ choices=["1/1 (Whole)", "1/2 (Half)", "1/4 (Quarter)", "1/8 (Eighth)", "1/16 (Sixteenth)"],
314
+ value="1/16 (Sixteenth)",
315
+ label="Grid Resolution"
316
+ )
317
+
318
+ gr.Markdown("### 3) Create Pack")
319
  slice_all_button = gr.Button("Slice All Stems & Create Pack", variant="primary")
320
  download_zip_file = gr.File(label="Download Your Loop Pack", visible=False)
321
 
322
  gr.Markdown("### Status")
323
+ status_log = gr.Textbox(label="Status Log", lines=12, interactive=False)
 
 
324
 
325
  with gr.Column(scale=2):
326
  with gr.Accordion("Separated Stems", open=True):
327
  with gr.Row():
328
+ vocals_output = gr.Audio(type="filepath", label="Vocals", scale=4)
329
  slice_vocals_btn = gr.Button("Slice Vocals", scale=1)
330
  with gr.Row():
331
+ drums_output = gr.Audio(type="filepath", label="Drums", scale=4)
332
  slice_drums_btn = gr.Button("Slice Drums", scale=1)
333
  with gr.Row():
334
+ bass_output = gr.Audio(type="filepath", label="Bass", scale=4)
335
  slice_bass_btn = gr.Button("Slice Bass", scale=1)
336
  with gr.Row():
337
+ other_output = gr.Audio(type="filepath", label="Other / Instrumental", scale=4)
338
  slice_other_btn = gr.Button("Slice Other", scale=1)
 
 
339
 
340
+ gr.Markdown("### Sliced Files")
341
+ loops_files = gr.Files(label="Generated Loops / One-Shots")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
 
343
+ # wire events
344
+ submit_button.click(
 
 
 
 
 
345
  fn=separate_stems,
346
  inputs=[audio_input, stem_options],
347
  outputs=[vocals_output, drums_output, bass_output, other_output, status_log]
348
  )
349
 
350
+ stem_options.change(
351
+ fn=update_output_visibility,
352
+ inputs=stem_options,
353
+ outputs=[vocals_output, drums_output, bass_output, other_output]
354
+ )
355
 
356
+ def slice_and_display(stem_path, loop_choice, sensitivity, stem_name, bpm_over, quantize_on, grid_sel):
357
+ log_history = f"Slicing {stem_name}...\n"
358
+ def _p(p, desc=""):
359
+ gr.Progress(track_tqdm=True)(p, desc=desc)
360
+
361
+ files, temp_dir = slice_stem_real(
362
+ stem_path, loop_choice, sensitivity, stem_name,
363
+ progress_fn=_p,
364
+ bpm_override_val=int(bpm_over),
365
+ quantize=bool(quantize_on),
366
+ grid_label=grid_sel
367
+ )
368
+ if temp_dir and os.path.exists(temp_dir):
369
+ shutil.rmtree(temp_dir)
370
+
371
+ if files:
372
+ log_history += f"✅ Sliced {stem_name} into {len(files)} pieces."
373
+ return gr.update(value=files), log_history
374
+ else:
375
+ log_history += f"⚠️ No slices generated for {stem_name}."
376
+ return gr.update(value=None), log_history
377
+
378
+ slice_vocals_btn.click(
379
+ fn=slice_and_display,
380
+ inputs=[vocals_output, loop_options_radio, sensitivity_slider, gr.Textbox("vocals", visible=False),
381
+ bpm_override, quantize_toggle, grid_select],
382
+ outputs=[loops_files, status_log]
383
+ )
384
+ slice_drums_btn.click(
385
+ fn=slice_and_display,
386
+ inputs=[drums_output, loop_options_radio, sensitivity_slider, gr.Textbox("drums", visible=False),
387
+ bpm_override, quantize_toggle, grid_select],
388
+ outputs=[loops_files, status_log]
389
+ )
390
+ slice_bass_btn.click(
391
+ fn=slice_and_display,
392
+ inputs=[bass_output, loop_options_radio, sensitivity_slider, gr.Textbox("bass", visible=False),
393
+ bpm_override, quantize_toggle, grid_select],
394
+ outputs=[loops_files, status_log]
395
+ )
396
+ slice_other_btn.click(
397
+ fn=slice_and_display,
398
+ inputs=[other_output, loop_options_radio, sensitivity_slider, gr.Textbox("other", visible=False),
399
+ bpm_override, quantize_toggle, grid_select],
400
+ outputs=[loops_files, status_log]
401
+ )
402
 
403
+ slice_all_button.click(
404
  fn=slice_all_and_zip_real,
405
+ inputs=[vocals_output, drums_output, bass_output, other_output,
406
+ loop_options_radio, sensitivity_slider,
407
+ bpm_override, quantize_toggle, grid_select],
408
+ outputs=[status_log, download_zip_file]
409
  )
410
 
411
+ # Spaces calls launch() automatically
412
+ demo.queue(concurrency_count=1, max_size=8).launch()