import librosa import numpy as np import subprocess import os import sys import argparse import json import soundfile as sf import tempfile def fix_grid(input_file, output_file, target_bpm=None, verbose=False): temp_wav = None try: if verbose: print(f"[*] A carregar: {input_file}") # Carregar mantendo stereo e sample rate original y, sr = librosa.load(input_file, sr=None, mono=False) # Se for stereo, o librosa devolve (2, samples). # Algumas funções esperam (samples, 2) ou precisam de tratamento especial. y_mono = librosa.to_mono(y) if y.ndim > 1 else y if verbose: print("[*] A detetar transientes (picos) e analisar beats...") # Analisamos na versão mono para maior precisão de deteção onset_env = librosa.onset.onset_strength(y=y_mono, sr=sr) onset_frames = librosa.onset.onset_detect(onset_envelope=onset_env, sr=sr) tempo, beat_frames = librosa.beat.beat_track(onset_envelope=onset_env, sr=sr) # Forçar o alinhamento (snap) do batimento com o pico de onda mais próximo snapped_beat_frames = [] for beat in beat_frames: if len(onset_frames) > 0: nearest_onset = min(onset_frames, key=lambda x: abs(x - beat)) # Distância máxima aceite (aprox. 150ms) if abs(nearest_onset - beat) < librosa.time_to_frames(0.15, sr=sr): snapped_beat_frames.append(nearest_onset) else: snapped_beat_frames.append(beat) else: snapped_beat_frames.append(beat) beat_frames = np.array(snapped_beat_frames) if isinstance(tempo, np.ndarray): tempo = tempo[0] if target_bpm is None: target_bpm = round(float(tempo)) if verbose: print(f"[*] BPM alvo detetado: {tempo:.2f} -> Arredondado para: {target_bpm}") else: if verbose: print(f"[*] BPM alvo definido pelo utilizador: {target_bpm}") beat_times = librosa.frames_to_time(beat_frames, sr=sr) if len(beat_times) < 2: print("[!] Erro: Não foram detetados beats suficientes.") return # Criar WAV temporário para o Rubber Band (garante compatibilidade se o input for MP3) fd, temp_wav = tempfile.mkstemp(suffix=".wav") os.close(fd) if verbose: print(f"[*] A criar ficheiro temporário de trabalho...") # soundfile espera (samples, channels) sf.write(temp_wav, y.T if y.ndim > 1 else y, sr) # Calcular grid ideal t_ideal_interval = 60.0 / target_bpm first_beat_time = beat_times[0] timemap_path = "timemap.txt" valid_points = 0 with open(timemap_path, "w") as f: last_target_frame = -1 for t_real in beat_times: # O Librosa pode saltar beats ou detetar notas fantasma. # Mapeamos o tempo real para o múltiplo ideal mais próximo, # saltando possíveis detecções duplicadas e não desfazendo a grelha por beats em falta. delta = t_real - first_beat_time beats_elapsed = round(delta / t_ideal_interval) t_target = first_beat_time + (beats_elapsed * t_ideal_interval) source_frame = int(t_real * sr) target_frame = int(t_target * sr) if target_frame > last_target_frame: f.write(f"{source_frame} {target_frame}\n") last_target_frame = target_frame valid_points += 1 if verbose: print(f"[*] Timemap gerado com {valid_points} pontos de correção válidos.") if verbose: print("[*] A aplicar warping com Rubber Band (Motor R3)...") # O rácio global ajuda o rubberband a estimar a duração final ratio = float(tempo) / target_bpm cmd = [ "rubberband", "--fine", f"-T{ratio}", "-M", timemap_path, temp_wav, output_file ] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: print(f"[!] Erro no Rubber Band: {result.stderr}") else: if verbose: print(f"[+] Sucesso! Ficheiro exportado: {output_file}") if os.path.exists(timemap_path): os.remove(timemap_path) # Metadados metadata = { "input": input_file, "original_bpm": float(tempo), "target_bpm": float(target_bpm), "beats_count": len(beat_times), "first_beat_time": float(first_beat_time) } meta_path = output_file.rsplit('.', 1)[0] + ".json" with open(meta_path, "w") as f: json.dump(metadata, f, indent=4) except Exception as e: print(f"[!] Ocorreu um erro: {str(e)}") finally: if temp_wav and os.path.exists(temp_wav): os.remove(temp_wav) if __name__ == "__main__": parser = argparse.ArgumentParser(description="Forca-Grid: Corrige o drift de BPM em faixas de áudio.") parser.add_argument("input", help="Ficheiro de áudio de entrada") parser.add_argument("output", help="Ficheiro de áudio de saída") parser.add_argument("--bpm", type=float, help="BPM alvo") parser.add_argument("-v", "--verbose", action="store_true", help="Detalhes") args = parser.parse_args() fix_grid(args.input, args.output, args.bpm, args.verbose)