SaltProphet commited on
Commit
819dfa0
·
verified ·
1 Parent(s): 7272862

Create Loopy.py

Browse files
Files changed (1) hide show
  1. Loopy.py +421 -0
Loopy.py ADDED
@@ -0,0 +1,421 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ 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
+
15
+ # Use a non-interactive backend for Matplotlib
16
+ matplotlib.use('Agg')
17
+
18
+ def update_output_visibility(choice):
19
+ """Updates the visibility of stem outputs based on user's choice."""
20
+ if "2 Stems" in choice:
21
+ return {
22
+ vocals_output: gr.update(visible=True),
23
+ drums_output: gr.update(visible=False),
24
+ bass_output: gr.update(visible=False),
25
+ other_output: gr.update(visible=True, label="Instrumental (No Vocals)")
26
+ }
27
+ elif "4 Stems" in choice:
28
+ return {
29
+ vocals_output: gr.update(visible=True),
30
+ drums_output: gr.update(visible=True),
31
+ bass_output: gr.update(visible=True),
32
+ other_output: gr.update(visible=True, label="Other")
33
+ }
34
+
35
+ async def separate_stems(audio_file_path, stem_choice):
36
+ """
37
+ Separates audio into stems using Demucs.
38
+ This is an async generator function that yields updates to the UI.
39
+ """
40
+ if audio_file_path is None:
41
+ raise gr.Error("No audio file uploaded!")
42
+
43
+ log_history = "Starting separation...\n"
44
+ yield {
45
+ status_log: log_history
46
+ }
47
+
48
+ try:
49
+ log_history += "Preparing audio file...\n"
50
+ yield {
51
+ status_log: log_history
52
+ }
53
+
54
+ original_filename_base = os.path.basename(audio_file_path).rsplit('.', 1)[0]
55
+ # Create a stable path to avoid issues with temp files
56
+ stable_input_path = f"stable_input_{original_filename_base}.wav"
57
+ shutil.copy(audio_file_path, stable_input_path)
58
+
59
+ model_arg = "--two-stems=vocals" if "2 Stems" in stem_choice else ""
60
+ output_dir = "separated"
61
+ if os.path.exists(output_dir):
62
+ shutil.rmtree(output_dir)
63
+
64
+ command = f"python3 -m demucs {model_arg} -o \"{output_dir}\" \"{stable_input_path}\""
65
+
66
+ log_history += f"Running Demucs command: {command}\n(This may take a minute or more depending on track length)\n"
67
+ yield {
68
+ status_log: log_history
69
+ }
70
+
71
+ process = await asyncio.create_subprocess_shell(
72
+ command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
73
+
74
+ stdout, stderr = await process.communicate()
75
+
76
+ if process.returncode != 0:
77
+ print(f"Demucs Error: {stderr.decode()}")
78
+ # Enhanced error message to explicitly tell the user about the missing dependency
79
+ raise gr.Error(f"Demucs failed. You must install the 'demucs' package first! Error: {stderr.decode()[:500]}")
80
+
81
+ log_history += "Demucs finished. Locating stem files...\n"
82
+ yield {
83
+ status_log: log_history
84
+ }
85
+
86
+ stable_filename_base = os.path.basename(stable_input_path).rsplit('.', 1)[0]
87
+ subfolders = [f.name for f in os.scandir(output_dir) if f.is_dir()]
88
+ if not subfolders:
89
+ raise gr.Error("Demucs output folder structure not found!")
90
+
91
+ # Assume the first subfolder is the one Demucs created
92
+ model_folder_name = subfolders[0]
93
+ stems_path = os.path.join(output_dir, model_folder_name, stable_filename_base)
94
+
95
+ if not os.path.exists(stems_path):
96
+ # Fallback: sometimes demucs doesn't create the innermost folder
97
+ stems_path = os.path.join(output_dir, model_folder_name)
98
+ if not (os.path.exists(os.path.join(stems_path, "vocals.wav")) or os.path.exists(os.path.join(stems_path, "no_vocals.wav"))):
99
+ raise gr.Error(f"Demucs output directory was not found! Looked for: {stems_path}")
100
+
101
+
102
+ vocals_path = os.path.join(stems_path, "vocals.wav") if os.path.exists(os.path.join(stems_path, "vocals.wav")) else None
103
+ drums_path = os.path.join(stems_path, "drums.wav") if os.path.exists(os.path.join(stems_path, "drums.wav")) else None
104
+ bass_path = os.path.join(stems_path, "bass.wav") if os.path.exists(os.path.join(stems_path, "bass.wav")) else None
105
+
106
+ other_filename = "no_vocals.wav" if "2 Stems" in stem_choice else "other.wav"
107
+ other_path = os.path.join(stems_path, other_filename) if os.path.exists(os.path.join(stems_path, other_filename)) else None
108
+
109
+ # Clean up the copied input file
110
+ os.remove(stable_input_path)
111
+
112
+ log_history += "✅ Stem separation complete!\n"
113
+ yield {
114
+ status_log: log_history,
115
+ vocals_output: gr.update(value=vocals_path),
116
+ drums_output: gr.update(value=drums_path),
117
+ bass_output: gr.update(value=bass_path),
118
+ other_output: gr.update(value=other_path)
119
+ }
120
+
121
+ except Exception as e:
122
+ print(f"An error occurred during separation: {e}")
123
+ # Clean up input file on error too
124
+ if 'stable_input_path' in locals() and os.path.exists(stable_input_path):
125
+ os.remove(stable_input_path)
126
+ yield {
127
+ status_log: log_history + f"❌ ERROR: {e}"
128
+ }
129
+
130
+ def slice_stem_real(stem_audio_data, loop_choice, sensitivity, stem_name, progress_fn=None):
131
+ """
132
+ The core logic for slicing a single stem.
133
+ Accepts an optional progress_fn callback for use in loops.
134
+ """
135
+ if stem_audio_data is None:
136
+ return None, None
137
+
138
+ sample_rate, y_int = stem_audio_data
139
+ # Convert from int16 to float
140
+ y = librosa.util.buf_to_float(y_int, dtype=np.float32)
141
+
142
+ # Ensure y is 1D or 2D
143
+ if y.ndim == 0:
144
+ print(f"Warning: Empty audio data for {stem_name}.")
145
+ return None, None
146
+
147
+ y_mono = librosa.to_mono(y.T) if y.ndim > 1 else y
148
+
149
+ if progress_fn: progress_fn(0.1, desc="Detecting BPM...")
150
+
151
+ tempo, beats = librosa.beat.beat_track(y=y_mono, sr=sample_rate)
152
+ bpm = 120 if tempo is None or tempo == 0 else np.round(tempo)
153
+ bpm_int = int(bpm.item())
154
+
155
+ if bpm_int == 0:
156
+ bpm_int = 120 # Final fallback
157
+ print("BPM detection failed, defaulting to 120 BPM.")
158
+
159
+ print(f"Detected BPM for {stem_name}: {bpm_int}")
160
+
161
+ output_files = []
162
+ loops_dir = tempfile.mkdtemp()
163
+
164
+ if "One-Shots" in loop_choice:
165
+ if progress_fn: progress_fn(0.3, desc="Finding transients...")
166
+ # Adjust onset detection parameters
167
+ onset_frames = librosa.onset.onset_detect(
168
+ y=y_mono, sr=sample_rate, delta=sensitivity,
169
+ wait=1, pre_avg=1, post_avg=1, post_max=1, units='frames'
170
+ )
171
+ onset_samples = librosa.frames_to_samples(onset_frames)
172
+
173
+ if progress_fn: progress_fn(0.5, desc="Slicing one-shots...")
174
+
175
+ if len(onset_samples) > 0:
176
+ num_onsets = len(onset_samples)
177
+ for i, start_sample in enumerate(onset_samples):
178
+ end_sample = onset_samples[i+1] if i+1 < num_onsets else len(y)
179
+ # Use original stereo/mono data for the slice
180
+ slice_data = y[start_sample:end_sample]
181
+
182
+ filename = os.path.join(loops_dir, f"{stem_name}_one_shot_{i+1:03d}.wav")
183
+ sf.write(filename, slice_data, sample_rate, subtype='PCM_16')
184
+ output_files.append(filename)
185
+
186
+ if progress_fn and num_onsets > 1 and i > 0:
187
+ progress = 0.5 + (i / (num_onsets - 1) * 0.5)
188
+ progress_fn(progress, desc=f"Exporting slice {i+1}/{num_onsets}...")
189
+ else:
190
+ # Loop slicing logic
191
+ bars = int(loop_choice.split(" ")[0])
192
+ seconds_per_beat = 60.0 / bpm_int
193
+ seconds_per_bar = seconds_per_beat * 4 # Assuming 4/4 time
194
+
195
+ loop_duration_seconds = seconds_per_bar * bars
196
+ loop_duration_samples = int(loop_duration_seconds * sample_rate)
197
+
198
+ if loop_duration_samples == 0:
199
+ print(f"Loop duration is 0 for {stem_name}. BPM: {bpm_int}")
200
+ return None, None
201
+
202
+ if progress_fn: progress_fn(0.4, desc=f"Slicing into {bars}-bar loops...")
203
+
204
+ num_loops = len(y) // loop_duration_samples
205
+
206
+ if num_loops == 0:
207
+ print(f"Audio for {stem_name} is too short for {bars}-bar loops at {bpm_int} BPM.")
208
+ return None, None
209
+
210
+ for i in range(num_loops):
211
+ start_sample = i * loop_duration_samples
212
+ end_sample = start_sample + loop_duration_samples
213
+ # Use original stereo/mono data for the slice
214
+ slice_data = y[start_sample:end_sample]
215
+
216
+ filename = os.path.join(loops_dir, f"{stem_name}_{bars}bar_loop_{i+1:03d}_{bpm_int}bpm.wav")
217
+ sf.write(filename, slice_data, sample_rate, subtype='PCM_16')
218
+ output_files.append(filename)
219
+
220
+ if progress_fn and num_loops > 1 and i > 0:
221
+ progress = 0.4 + (i / (num_loops - 1) * 0.6)
222
+ progress_fn(progress, desc=f"Exporting loop {i+1}/{num_loops}...")
223
+
224
+ if not output_files:
225
+ return None, None
226
+
227
+ return output_files, loops_dir
228
+
229
+ async def slice_all_and_zip_real(vocals, drums, bass, other, loop_choice, sensitivity):
230
+ """
231
+ Slices all available stems and packages them into a ZIP file.
232
+ This is an async generator function that yields updates to the UI.
233
+ """
234
+ log_history = "Starting batch slice...\n"
235
+ yield {
236
+ status_log: log_history
237
+ }
238
+ await asyncio.sleep(0.1) # Give UI time to update
239
+
240
+ stems_to_process = {"vocals": vocals, "drums": drums, "bass": bass, "other": other}
241
+ zip_path = "Loop_Architect_Pack.zip"
242
+
243
+ num_stems = sum(1 for data in stems_to_process.values() if data is not None)
244
+ if num_stems == 0:
245
+ raise gr.Error("No stems to process! Please separate stems first.")
246
+
247
+ all_temp_dirs = []
248
+ try:
249
+ with zipfile.ZipFile(zip_path, 'w') as zf:
250
+ processed_count = 0
251
+ for name, data in stems_to_process.items():
252
+ if data is not None:
253
+ log_history += f"--- Slicing {name} stem ---\n"
254
+ yield { status_log: log_history }
255
+
256
+ # Simple progress function: cannot update Gradio UI inside slice_stem_real
257
+ def simple_progress_callback(p, desc=""):
258
+ # Log to console for debugging, but no UI update
259
+ pass
260
+
261
+ sliced_files, temp_dir = slice_stem_real(
262
+ (data[0], data[1]), loop_choice, sensitivity, name,
263
+ progress_fn=simple_progress_callback
264
+ )
265
+
266
+ if sliced_files:
267
+ log_history += f"Generated {len(sliced_files)} slices for {name}.\n"
268
+ all_temp_dirs.append(temp_dir)
269
+ for loop_file in sliced_files:
270
+ arcname = os.path.join(name, os.path.basename(loop_file))
271
+ zf.write(loop_file, arcname)
272
+ else:
273
+ log_history += f"No slices generated for {name}.\n"
274
+
275
+ processed_count += 1
276
+ yield {
277
+ status_log: log_history
278
+ }
279
+
280
+ log_history += "Packaging complete!\n"
281
+ yield {
282
+ status_log: log_history + "✅ Pack ready for download!",
283
+ download_zip_file: gr.update(value=zip_path, visible=True)
284
+ }
285
+
286
+ except Exception as e:
287
+ print(f"An error occurred during slice all: {e}")
288
+ yield {
289
+ status_log: log_history + f"❌ ERROR: {e}"
290
+ }
291
+ finally:
292
+ # Clean up all temporary directories
293
+ for d in all_temp_dirs:
294
+ if d and os.path.exists(d):
295
+ shutil.rmtree(d)
296
+
297
+ # --- Create the full Gradio Interface ---
298
+ with gr.Blocks(theme=gr.themes.Default(primary_hue="blue", secondary_hue="red")) as demo:
299
+ gr.Markdown("# 🎵 Loop Architect")
300
+ gr.Markdown("Upload any song to separate it into stems (vocals, drums, etc.) and then slice those stems into loops or one-shot samples.")
301
+
302
+ with gr.Row():
303
+ with gr.Column(scale=1):
304
+ gr.Markdown("### 1. Separate Stems")
305
+ audio_input = gr.Audio(type="filepath", label="Upload a Track")
306
+ stem_options = gr.Radio(
307
+ ["4 Stems (Vocals, Drums, Bass, Other)", "2 Stems (Vocals + Instrumental)"],
308
+ label="Separation Type",
309
+ value="4 Stems (Vocals, Drums, Bass, Other)"
310
+ )
311
+ submit_button = gr.Button("Separate Stems", variant="primary")
312
+
313
+ gr.Markdown("### 2. Slicing Options")
314
+ with gr.Group():
315
+ loop_options_radio = gr.Radio(
316
+ ["One-Shots (All Transients)", "4 Bar Loops", "8 Bar Loops"],
317
+ label="Slice Type",
318
+ value="One-Shots (All Transients)"
319
+ )
320
+ sensitivity_slider = gr.Slider(
321
+ minimum=0.01, maximum=0.5, value=0.05, step=0.01,
322
+ label="One-Shot Sensitivity",
323
+ info="Lower values = more slices"
324
+ )
325
+
326
+ gr.Markdown("### 3. Create Pack")
327
+ slice_all_button = gr.Button("Slice All Stems & Create Pack")
328
+ download_zip_file = gr.File(label="Download Your Loop Pack", visible=False)
329
+
330
+ gr.Markdown("### Status")
331
+ status_log = gr.Textbox(label="Status Log", lines=10, interactive=False)
332
+
333
+ with gr.Column(scale=2):
334
+ with gr.Accordion("Separated Stems (Preview & Slice)", open=True):
335
+ with gr.Row():
336
+ vocals_output = gr.Audio(label="Vocals", scale=4)
337
+ slice_vocals_btn = gr.Button("Slice Vocals", scale=1)
338
+ with gr.Row():
339
+ drums_output = gr.Audio(label="Drums", scale=4)
340
+ slice_drums_btn = gr.Button("Slice Drums", scale=1)
341
+ with gr.Row():
342
+ bass_output = gr.Audio(label="Bass", scale=4)
343
+ slice_bass_btn = gr.Button("Slice Bass", scale=1)
344
+ with gr.Row():
345
+ other_output = gr.Audio(label="Other / Instrumental", scale=4)
346
+ slice_other_btn = gr.Button("Slice Other", scale=1)
347
+
348
+ gr.Markdown("### Sliced Loops / Samples (Preview)")
349
+ loop_gallery = gr.Gallery(
350
+ label="Generated Loops Preview",
351
+ columns=8, object_fit="contain", height="auto", preview=True
352
+ )
353
+
354
+ # --- Define Event Listeners ---
355
+ def slice_and_display(stem_data, loop_choice, sensitivity, stem_name):
356
+ """
357
+ Generator function to slice a single stem and update UI.
358
+ """
359
+ if stem_data is None:
360
+ yield {
361
+ status_log: f"No {stem_name} audio data to slice."
362
+ }
363
+ return
364
+
365
+ log_history = f"Slicing {stem_name}...\n"
366
+ yield {
367
+ status_log: log_history
368
+ }
369
+
370
+ # Define a callback for slice_stem_real to update this generator's progress
371
+ def update_single_progress(p, desc=""):
372
+ # This is a console log for debugging, as Gradio doesn't allow UI yield from inside here
373
+ print(f"Slice Progress {stem_name}: {p} - {desc}")
374
+
375
+ files, temp_dir = slice_stem_real(
376
+ stem_data, loop_choice, sensitivity, stem_name,
377
+ progress_fn=update_single_progress # Pass the callback
378
+ )
379
+
380
+ if temp_dir and os.path.exists(temp_dir):
381
+ shutil.rmtree(temp_dir) # Clean up temp dir
382
+
383
+ yield {
384
+ loop_gallery: gr.update(value=files),
385
+ status_log: log_history + f"✅ Sliced {stem_name} into {len(files) if files else 0} pieces."
386
+ }
387
+
388
+ # --- MAIN EVENT LISTENERS ---
389
+
390
+ # This event separates the stems
391
+ submit_event = submit_button.click(
392
+ fn=separate_stems,
393
+ inputs=[audio_input, stem_options],
394
+ outputs=[
395
+ vocals_output, drums_output, bass_output, other_output,
396
+ status_log
397
+ ]
398
+ )
399
+
400
+ # This event updates the UI based on the stem choice
401
+ stem_options.change(
402
+ fn=update_output_visibility,
403
+ inputs=stem_options,
404
+ outputs=[vocals_output, drums_output, bass_output, other_output]
405
+ )
406
+
407
+ # --- Single Slice Button Events ---
408
+ 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])
409
+ 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])
410
+ 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])
411
+ 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])
412
+
413
+ # This event slices all stems and zips them
414
+ slice_all_event = slice_all_button.click(
415
+ fn=slice_all_and_zip_real,
416
+ inputs=[vocals_output, drums_output, bass_output, other_output, loop_options_radio, sensitivity_slider],
417
+ outputs=[download_zip_file, status_log]
418
+ )
419
+
420
+ if __name__ == "__main__":
421
+ demo.launch(debug=True)