SaltProphet commited on
Commit
369f0ca
·
verified ·
1 Parent(s): 9011313

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +356 -1
app.py CHANGED
@@ -534,4 +534,359 @@ def slice_stem_real(
534
  try:
535
  current_index = pitch_classes.index(root)
536
  new_index = (current_index + transpose_semitones) % 12
537
- new_key_root = pitch_classes
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
534
  try:
535
  current_index = pitch_classes.index(root)
536
  new_index = (current_index + transpose_semitones) % 12
537
+ new_key_root = pitch_classes[new_index]
538
+ key_tag = f"{new_key_root}{mode}Shift"
539
+ except ValueError:
540
+ key_tag = f"Shifted{transpose_semitones}" # Fallback
541
+
542
+ # --- 6. MIDI GENERATION (Melodic Stems) ---
543
+ output_files = []
544
+ loops_dir = tempfile.mkdtemp()
545
+ is_melodic = stem_name in ["vocals", "bass", "guitar", "piano", "other"]
546
+
547
+ if is_melodic and ("Bar Loops" in loop_choice):
548
+ try:
549
+ y_mono_for_midi = librosa.to_mono(y)
550
+ # Use piptrack for pitch detection
551
+ pitches, magnitudes = librosa.piptrack(y=y_mono_for_midi, sr=sample_rate)
552
+
553
+ # Get the dominant pitch at each frame
554
+ main_pitch_line = np.zeros(pitches.shape[1])
555
+ for t in range(pitches.shape[1]):
556
+ index = magnitudes[:, t].argmax()
557
+ main_pitch_line[t] = pitches[index, t]
558
+
559
+ notes_list = []
560
+ i = 0
561
+ hop_length = 512 # Default hop for piptrack
562
+
563
+ while i < len(main_pitch_line):
564
+ current_freq = main_pitch_line[i]
565
+ current_midi = freq_to_midi(current_freq)
566
+ if current_midi == 0: # Skip silence/unpitched
567
+ i += 1
568
+ continue
569
+
570
+ # Find end of this note
571
+ j = i
572
+ while j < len(main_pitch_line) and freq_to_midi(main_pitch_line[j]) == current_midi:
573
+ j += 1
574
+
575
+ duration_frames = j - i
576
+ # Only add notes that are long enough (e.g., > 2 frames)
577
+ if duration_frames >= 2:
578
+ start_sec = librosa.frames_to_time(i, sr=sample_rate, hop_length=hop_length)
579
+ duration_sec = librosa.frames_to_time(duration_frames, sr=sample_rate, hop_length=hop_length)
580
+ notes_list.append((current_midi, start_sec, duration_sec))
581
+
582
+ i = j
583
+
584
+ if notes_list:
585
+ full_stem_midi_path = os.path.join(loops_dir, f"{stem_name}_MELODY_{key_tag}_{bpm_int}BPM.mid")
586
+ write_midi_file(notes_list, manual_bpm, full_stem_midi_path)
587
+ output_files.append(full_stem_midi_path)
588
+
589
+ except Exception as e:
590
+ print(f"MIDI generation failed for {stem_name}: {e}")
591
+
592
+ # --- 7. CALCULATE TIMING & SLICING ---
593
+ beats_per_bar = 4
594
+ if time_signature == "3/4":
595
+ beats_per_bar = 3
596
+
597
+ if "Bar Loops" in loop_choice:
598
+ bars = int(loop_choice.split(" ")[0])
599
+ loop_type_tag = f"{bars}Bar"
600
+ loop_duration_samples = int((60.0 / manual_bpm * beats_per_bar * bars) * sample_rate)
601
+ fade_samples = int((crossfade_ms / 1000.0) * sample_rate)
602
+
603
+ if loop_duration_samples > 0 and len(y) > loop_duration_samples:
604
+ num_loops = len(y) // loop_duration_samples
605
+ for i in range(min(num_loops, 16)): # Limit to 16 loops
606
+ start_sample = i * loop_duration_samples
607
+ end_sample = min(start_sample + loop_duration_samples, len(y))
608
+ slice_data = y[start_sample:end_sample]
609
+
610
+ # Apply crossfade
611
+ slice_data = apply_crossfade(slice_data, fade_samples)
612
+
613
+ filename = os.path.join(loops_dir, f"{stem_name}_{loop_type_tag}_{i+1:03d}_{key_tag}_{bpm_int}BPM.wav")
614
+ sf.write(filename, slice_data, sample_rate, subtype='PCM_16')
615
+ output_files.append(filename)
616
+
617
+ elif "One-Shots" in loop_choice:
618
+ loop_type_tag = "OneShot"
619
+ y_mono_for_onsets = librosa.to_mono(y)
620
+
621
+ # IMPLEMENTED: Use sensitivity to find onsets
622
+ # Adjust 'wait' and 'delta' based on sensitivity (0-1)
623
+ # Higher sensitivity = lower delta, shorter wait
624
+ delta = 0.5 * (1.0 - sensitivity) # 0.0 -> 0.5
625
+ wait_sec = 0.1 * (1.0 - sensitivity) # 0.0 -> 0.1
626
+ wait_samples = int(wait_sec * sample_rate / 512) # in frames
627
+
628
+ onset_frames = librosa.onset.onset_detect(
629
+ y=y_mono_for_onsets,
630
+ sr=sample_rate,
631
+ units='frames',
632
+ backtrack=True,
633
+ delta=delta,
634
+ wait=wait_samples
635
+ )
636
+ onset_samples = librosa.frames_to_samples(onset_frames)
637
+
638
+ # Add end of file as the last "onset"
639
+ onset_samples = np.append(onset_samples, len(y))
640
+
641
+ for i in range(min(len(onset_samples) - 1, 40)): # Limit to 40 slices
642
+ start_sample = onset_samples[i]
643
+ end_sample = onset_samples[i+1]
644
+ slice_data = y[start_sample:end_sample]
645
+
646
+ if len(slice_data) < 100: # Skip tiny fragments
647
+ continue
648
+
649
+ # IMPLEMENTED: Apply attack/sustain envelope
650
+ slice_data = apply_envelope(slice_data, sample_rate, attack_gain, sustain_gain)
651
+
652
+ # Apply short fade-out to prevent clicks
653
+ slice_data = apply_crossfade(slice_data, int(0.005 * sample_rate)) # 5ms fade
654
+
655
+ filename = os.path.join(loops_dir, f"{stem_name}_{loop_type_tag}_{i+1:03d}_{key_tag}_{bpm_int}BPM.wav")
656
+ sf.write(filename, slice_data, sample_rate, subtype='PCM_16')
657
+ output_files.append(filename)
658
+
659
+ # --- 8. VISUALIZATION GENERATION ---
660
+ img_path = generate_waveform_preview(y, sample_rate, stem_name, loops_dir)
661
+
662
+ # Clean up the temp dir for the *next* run
663
+ # Gradio File components need the files to exist, so we don't delete loops_dir yet
664
+ # A more robust solution would use gr.TempFile() or manage cleanup
665
+
666
+ return output_files, img_path
667
+
668
+ except Exception as e:
669
+ print(f"Error processing stem {stem_name}: {e}")
670
+ import traceback
671
+ traceback.print_exc()
672
+ return [], None # Return empty on error
673
+
674
+
675
+ def slice_all_and_zip(
676
+ vocals_audio: Optional[Tuple[int, np.ndarray]],
677
+ drums_audio: Optional[Tuple[int, np.ndarray]],
678
+ bass_audio: Optional[Tuple[int, np.ndarray]],
679
+ other_audio: Optional[Tuple[int, np.ndarray]],
680
+ guitar_audio: Optional[Tuple[int, np.ndarray]],
681
+ piano_audio: Optional[Tuple[int, np.ndarray]],
682
+ loop_choice: str,
683
+ sensitivity: float,
684
+ manual_bpm: float,
685
+ time_signature: str,
686
+ crossfade_ms: int,
687
+ transpose_semitones: int,
688
+ detected_key: str,
689
+ pan_depth: float,
690
+ level_depth: float,
691
+ modulation_rate: str,
692
+ target_dbfs: float,
693
+ attack_gain: float,
694
+ sustain_gain: float,
695
+ filter_type: str,
696
+ filter_freq: float,
697
+ filter_depth: float,
698
+ progress: gr.Progress
699
+ ) -> Optional[str]:
700
+ """Slices all available stems and packages them into a ZIP file."""
701
+ try:
702
+ stems_to_process = {
703
+ "vocals": vocals_audio, "drums": drums_audio, "bass": bass_audio,
704
+ "other": other_audio, "guitar": guitar_audio, "piano": piano_audio
705
+ }
706
+
707
+ # Filter out None stems
708
+ valid_stems = {name: data for name, data in stems_to_process.items() if data is not None}
709
+
710
+ if not valid_stems:
711
+ raise gr.Error("No stems to process! Please separate stems first.")
712
+
713
+ # Create temporary directory for all outputs
714
+ temp_dir = tempfile.mkdtemp()
715
+ zip_path = os.path.join(temp_dir, "Loop_Architect_Pack.zip")
716
+
717
+ all_sliced_files = []
718
+
719
+ # Use progress tracker
720
+ progress(0, desc="Starting...")
721
+
722
+ num_stems = len(valid_stems)
723
+ for i, (name, data) in enumerate(valid_stems.items()):
724
+ progress((i+1)/num_stems, desc=f"Slicing {name}...")
725
+
726
+ # Process stem
727
+ sliced_files, _ = slice_stem_real(
728
+ data, loop_choice, sensitivity, name,
729
+ manual_bpm, time_signature, crossfade_ms, transpose_semitones, detected_key,
730
+ pan_depth, level_depth, modulation_rate, target_dbfs,
731
+ attack_gain, sustain_gain, filter_type, filter_freq, filter_depth
732
+ )
733
+ all_sliced_files.extend(sliced_files)
734
+
735
+ progress(0.9, desc="Zipping files...")
736
+ with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
737
+ for file_path in all_sliced_files:
738
+ if not file_path: continue
739
+ # Create a sane folder structure in the ZIP
740
+ file_type = os.path.splitext(file_path)[1][1:].upper() # WAV or MID
741
+ arcname = os.path.join(file_type, os.path.basename(file_path))
742
+ zf.write(file_path, arcname)
743
+
744
+ progress(1.0, desc="Done!")
745
+
746
+ # Clean up individual slice files (but not the zip dir)
747
+ for file_path in all_sliced_files:
748
+ if file_path and os.path.exists(file_path):
749
+ os.remove(file_path)
750
+
751
+ return zip_path
752
+
753
+ except Exception as e:
754
+ print(f"Error creating ZIP: {e}")
755
+ import traceback
756
+ traceback.print_exc()
757
+ raise gr.Error(f"Error creating ZIP: {str(e)}")
758
+
759
+ # --- GRADIO INTERFACE ---
760
+
761
+ with gr.Blocks(theme=gr.themes.Soft(primary_hue="blue", secondary_hue="red")) as demo:
762
+ gr.Markdown("# 🎵 Loop Architect (Pro Edition)")
763
+ gr.Markdown("Upload any song to separate it into stems, detect musical attributes, and then slice and tag the stems for instant use in a DAW.")
764
+
765
+ # State variables
766
+ detected_bpm_state = gr.State(value=120.0)
767
+ detected_key_state = gr.State(value="Unknown Key")
768
+ harmonic_recs_state = gr.State(value="---")
769
+
770
+ # Outputs for each stem (as gr.Audio tuples)
771
+ vocals_audio = gr.Audio(visible=False, type="numpy")
772
+ drums_audio = gr.Audio(visible=False, type="numpy")
773
+ bass_audio = gr.Audio(visible=False, type="numpy")
774
+ other_audio = gr.Audio(visible=False, type="numpy")
775
+ guitar_audio = gr.Audio(visible=False, type="numpy")
776
+ piano_audio = gr.Audio(visible=False, type="numpy")
777
+
778
+ stem_audio_outputs = [vocals_audio, drums_audio, bass_audio, other_audio, guitar_audio, piano_audio]
779
+
780
+ with gr.Row():
781
+ with gr.Column(scale=1):
782
+ # --- INPUT COLUMN ---
783
+ gr.Markdown("### 1. Upload & Analyze")
784
+ audio_input = gr.Audio(label="Upload Song", type="filepath")
785
+ separate_button = gr.Button("Separate Stems & Analyze", variant="primary")
786
+
787
+ with gr.Accordion("Global Musical Settings", open=True):
788
+ manual_bpm_input = gr.Number(label="BPM", value=120.0, step=0.1, interactive=True)
789
+ key_display = gr.Textbox(label="Detected Key", value="Unknown Key", interactive=False)
790
+ harmonic_recs_display = gr.Textbox(label="Harmonic Recommendations", value="---", interactive=False)
791
+ transpose_semitones = gr.Slider(label="Transpose (Semitones)", minimum=-12, maximum=12, value=0, step=1)
792
+ time_signature = gr.Radio(label="Time Signature", choices=["4/4", "3/4"], value="4/4")
793
+
794
+ with gr.Accordion("Global Slicing Settings", open=True):
795
+ loop_choice = gr.Radio(label="Loop Type", choices=["1 Bar Loops", "2 Bar Loops", "4 Bar Loops", "One-Shots"], value="4 Bar Loops")
796
+ sensitivity = gr.Slider(label="One-Shot Sensitivity", minimum=0.0, maximum=1.0, value=0.5, info="Higher = more slices")
797
+ crossfade_ms = gr.Slider(label="Loop Crossfade (ms)", minimum=0, maximum=50, value=10, step=1)
798
+
799
+ with gr.Accordion("Global FX Settings", open=False):
800
+ target_dbfs = gr.Slider(label="Normalize Peak to (dBFS)", minimum=-24.0, maximum=-0.0, value=-1.0, step=0.1, info="-0.0 = Off")
801
+
802
+ gr.Markdown("---")
803
+ gr.Markdown("**LFO Modulation (Pan/Level)**")
804
+ modulation_rate = gr.Radio(label="Modulation Rate", choices=["1/2", "1/4", "1/8", "1/16"], value="1/4")
805
+ pan_depth = gr.Slider(label="Pan Depth (%)", minimum=0, maximum=100, value=0, step=1)
806
+ level_depth = gr.Slider(label="Level Depth (%)", minimum=0, maximum=100, value=0, step=1, info="Tremolo effect")
807
+
808
+ gr.Markdown("---")
809
+ gr.Markdown("**LFO Modulation (Filter)**")
810
+ filter_type = gr.Radio(label="Filter Type", choices=["None", "Low-pass", "High-pass"], value="None")
811
+ filter_freq = gr.Slider(label="Filter Base Freq (Hz)", minimum=20, maximum=10000, value=5000, step=100)
812
+ filter_depth = gr.Slider(label="Filter Mod Depth (Hz)", minimum=0, maximum=10000, value=0, step=100, info="LFO amount")
813
+
814
+ gr.Markdown("---")
815
+ gr.Markdown("**One-Shot Shaping**")
816
+ attack_gain = gr.Slider(label="Attack Gain (dB)", minimum=-24.0, maximum=6.0, value=0.0, step=0.5, info="Gain at start of transient")
817
+ sustain_gain = gr.Slider(label="Sustain Gain (dB)", minimum=-24.0, maximum=6.0, value=0.0, step=0.5, info="Gain for note body")
818
+
819
+ gr.Markdown("### 3. Generate Pack")
820
+ slice_all_button = gr.Button("SLICE ALL & GENERATE PACK", variant="primary")
821
+ zip_file_output = gr.File(label="Download Your Loop Pack")
822
+
823
+ with gr.Column(scale=2):
824
+ # --- OUTPUT COLUMN ---
825
+ gr.Markdown("### 2. Review Stems & Slices")
826
+ with gr.Tabs():
827
+ # Create a tab for each stem
828
+ for i, name in enumerate(STEM_NAMES):
829
+ with gr.Tab(name.capitalize()):
830
+ with gr.Row():
831
+ # The (hidden) audio output for this stem
832
+ stem_audio_component = stem_audio_outputs[i]
833
+
834
+ # Visible components
835
+ preview_image = gr.Image(label="Processed Waveform", interactive=False)
836
+ slice_files = gr.Files(label="Generated Slices & MIDI", interactive=False)
837
+
838
+ # Add a button to slice just this one stem
839
+ slice_one_button = gr.Button(f"Slice This {name.capitalize()} Stem")
840
+
841
+ # Gather all global settings as inputs
842
+ all_settings = [
843
+ loop_choice, sensitivity, manual_bpm_input, time_signature, crossfade_ms,
844
+ transpose_semitones, detected_key_state, pan_depth, level_depth, modulation_rate,
845
+ target_dbfs, attack_gain, sustain_gain, filter_type, filter_freq, filter_depth
846
+ ]
847
+
848
+ # Wire up the "Slice One" button
849
+ slice_one_button.click(
850
+ fn=slice_stem_real,
851
+ inputs=[stem_audio_component, gr.State(value=name)] + all_settings,
852
+ outputs=[slice_files, preview_image]
853
+ )
854
+
855
+ # --- EVENT LISTENERS ---
856
+
857
+ # 1. "Separate Stems" button click
858
+ separate_button.click(
859
+ fn=separate_stems,
860
+ inputs=[audio_input],
861
+ outputs=stem_audio_outputs + [detected_bpm_state, detected_key_state, harmonic_recs_state]
862
+ )
863
+
864
+ # 2. When BPM state changes, update the visible input box
865
+ detected_bpm_state.change(
866
+ fn=lambda x: x,
867
+ inputs=[detected_bpm_state],
868
+ outputs=[manual_bpm_input]
869
+ )
870
+
871
+ # 3. When Key state changes, update the visible text boxes
872
+ detected_key_state.change(
873
+ fn=lambda x: x,
874
+ inputs=[detected_key_state],
875
+ outputs=[key_display]
876
+ )
877
+ harmonic_recs_state.change(
878
+ fn=lambda x: x,
879
+ inputs=[harmonic_recs_state],
880
+ outputs=[harmonic_recs_display]
881
+ )
882
+
883
+ # 4. "SLICE ALL" button click
884
+ slice_all_button.click(
885
+ fn=slice_all_and_zip,
886
+ inputs=stem_audio_outputs + all_settings,
887
+ outputs=[zip_file_output]
888
+ )
889
+
890
+
891
+ if __name__ == "__main__":
892
+ demo.launch(debug=True)