diff --git a/plesk/domainstatus.sh b/plesk/domainstatus.sh index b1345ba..352d530 100644 --- a/plesk/domainstatus.sh +++ b/plesk/domainstatus.sh @@ -17,7 +17,7 @@ SERVER=$(curl -s --connect-timeout 2 -4 ifconfig.me || hostname -I | awk '{print # --- PRE-CALCULO DE PROCESOS PHP-FPM --- declare -A PROCESS_MAP -echo "Analizando procesos PHP-FPM..." +echo "Analyzing PHP-FPM processes..." while read -r count domain; do domain=$(echo "$domain" | xargs) @@ -32,7 +32,7 @@ else fi # --- IMPRIMIR CABECERA --- -printf "%-30s %-8s %-12s %-12s %-12s %-10s %-10s %-20s %-15s\n" "DOMINIO" "PROCS" "RAM (Lim)" "UPLOAD" "POST" "TIME" "PHP VER" "HANDLER" "IP (DNS)" +printf "%-30s %-8s %-12s %-12s %-12s %-10s %-10s %-20s %-15s\n" "DOMAIN" "PROCS" "RAM (Lim)" "UPLOAD" "POST" "TIME" "PHP VER" "HANDLER" "IP (DNS)" printf "%s\n" "------------------------------------------------------------------------------------------------------------------------------------------------" for DOMAIN in $DOMAINS; do diff --git a/spotify/Stream_History_To_csv_png.py b/spotify/Stream_History_To_csv_png.py new file mode 100644 index 0000000..5a9c45e --- /dev/null +++ b/spotify/Stream_History_To_csv_png.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 + +import json +import pandas as pd +import matplotlib.pyplot as plt +import seaborn as sns +from pathlib import Path +from datetime import datetime + +# Configuración de estilo para los gráficos +plt.style.use('seaborn-v0_8-darkgrid') +sns.set_palette("husl") + +def cargar_archivos_json(directorio='.'): + """ + Carga todos los archivos JSON del historial de Spotify + """ + archivos = Path(directorio).glob('Streaming_History_Audio_*.json') + todos_los_datos = [] + + for archivo in archivos: + print(f"Cargando {archivo.name}...") + with open(archivo, 'r', encoding='utf-8') as f: + datos = json.load(f) + todos_los_datos.extend(datos) + + print(f"Total de reproducciones cargadas: {len(todos_los_datos)}") + return todos_los_datos + +def procesar_datos(datos): + """ + Convierte los datos JSON en un DataFrame de pandas y procesa las fechas + """ + df = pd.DataFrame(datos) + + # Convertir timestamp a datetime + df['ts'] = pd.to_datetime(df['ts']) + + # Convertir milisegundos a minutos + df['minutos_reproducidos'] = df['ms_played'] / 60000 + + # Extraer año, mes, día + df['año'] = df['ts'].dt.year + df['mes'] = df['ts'].dt.month + df['año_mes'] = df['ts'].dt.to_period('M') + df['nombre_mes'] = df['ts'].dt.strftime('%B %Y') + + # Filtrar solo canciones (no podcasts ni audiolibros) + df = df[df['master_metadata_track_name'].notna()] + + return df + +def minutos_por_mes(df): + """ + Calcula y grafica los minutos reproducidos por mes + """ + minutos_mes = df.groupby('año_mes')['minutos_reproducidos'].sum().reset_index() + minutos_mes['año_mes'] = minutos_mes['año_mes'].astype(str) + + plt.figure(figsize=(15, 6)) + plt.bar(minutos_mes['año_mes'], minutos_mes['minutos_reproducidos'], color='#1DB954') + plt.xlabel('Mes', fontsize=12) + plt.ylabel('Minutos Reproducidos', fontsize=12) + plt.title('Minutos Reproducidos por Mes', fontsize=16, fontweight='bold') + plt.xticks(rotation=45, ha='right') + plt.tight_layout() + plt.savefig('minutos_por_mes.png', dpi=300, bbox_inches='tight') + plt.close() + + return minutos_mes + +def top_artistas_mes(df, año, mes, top_n=10): + """ + Obtiene el top 10 de artistas de un mes específico + """ + df_mes = df[(df['año'] == año) & (df['mes'] == mes)] + + if len(df_mes) == 0: + return None + + top_artistas = df_mes.groupby('master_metadata_album_artist_name')['minutos_reproducidos'].sum().sort_values(ascending=False).head(top_n) + + plt.figure(figsize=(12, 8)) + top_artistas.plot(kind='barh', color='#1DB954') + plt.xlabel('Minutos Reproducidos', fontsize=12) + plt.ylabel('Artista', fontsize=12) + plt.title(f'Top {top_n} Artistas - {mes:02d}/{año}', fontsize=16, fontweight='bold') + plt.gca().invert_yaxis() + plt.tight_layout() + plt.savefig(f'top_artistas_{año}_{mes:02d}.png', dpi=300, bbox_inches='tight') + plt.close() + + return top_artistas + +def top_artistas_año(df, año, top_n=10): + """ + Obtiene el top 10 de artistas de un año específico + """ + df_año = df[df['año'] == año] + + if len(df_año) == 0: + return None + + top_artistas = df_año.groupby('master_metadata_album_artist_name')['minutos_reproducidos'].sum().sort_values(ascending=False).head(top_n) + + plt.figure(figsize=(12, 8)) + top_artistas.plot(kind='barh', color='#1ED760') + plt.xlabel('Minutos Reproducidos', fontsize=12) + plt.ylabel('Artista', fontsize=12) + plt.title(f'Top {top_n} Artistas - {año}', fontsize=16, fontweight='bold') + plt.gca().invert_yaxis() + plt.tight_layout() + plt.savefig(f'top_artistas_{año}.png', dpi=300, bbox_inches='tight') + plt.close() + + return top_artistas + +def top_canciones_mes(df, año, mes, top_n=10): + """ + Obtiene el top 10 de canciones de un mes específico + """ + df_mes = df[(df['año'] == año) & (df['mes'] == mes)] + + if len(df_mes) == 0: + return None + + df_mes['cancion_artista'] = df_mes['master_metadata_track_name'] + ' - ' + df_mes['master_metadata_album_artist_name'] + top_canciones = df_mes.groupby('cancion_artista')['minutos_reproducidos'].sum().sort_values(ascending=False).head(top_n) + + plt.figure(figsize=(12, 10)) + top_canciones.plot(kind='barh', color='#1DB954') + plt.xlabel('Minutos Reproducidos', fontsize=12) + plt.ylabel('Canción', fontsize=12) + plt.title(f'Top {top_n} Canciones - {mes:02d}/{año}', fontsize=16, fontweight='bold') + plt.gca().invert_yaxis() + plt.tight_layout() + plt.savefig(f'top_canciones_{año}_{mes:02d}.png', dpi=300, bbox_inches='tight') + plt.close() + + return top_canciones + +def top_canciones_año(df, año, top_n=10): + """ + Obtiene el top 10 de canciones de un año específico + """ + df_año = df[df['año'] == año] + + if len(df_año) == 0: + return None + + df_año['cancion_artista'] = df_año['master_metadata_track_name'] + ' - ' + df_año['master_metadata_album_artist_name'] + top_canciones = df_año.groupby('cancion_artista')['minutos_reproducidos'].sum().sort_values(ascending=False).head(top_n) + + plt.figure(figsize=(12, 10)) + top_canciones.plot(kind='barh', color='#1ED760') + plt.xlabel('Minutos Reproducidos', fontsize=12) + plt.ylabel('Canción', fontsize=12) + plt.title(f'Top {top_n} Canciones - {año}', fontsize=16, fontweight='bold') + plt.gca().invert_yaxis() + plt.tight_layout() + plt.savefig(f'top_canciones_{año}.png', dpi=300, bbox_inches='tight') + plt.close() + + return top_canciones + +def resumen_estadisticas(df): + """ + Muestra un resumen de estadísticas generales + """ + print("\n" + "="*60) + print("RESUMEN DE ESTADÍSTICAS") + print("="*60) + print(f"Total de reproducciones: {len(df):,}") + print(f"Total de minutos reproducidos: {df['minutos_reproducidos'].sum():,.2f}") + print(f"Total de horas reproducidas: {df['minutos_reproducidos'].sum()/60:,.2f}") + print(f"Artistas únicos: {df['master_metadata_album_artist_name'].nunique():,}") + print(f"Canciones únicas: {df['master_metadata_track_name'].nunique():,}") + print(f"Período: {df['ts'].min().date()} a {df['ts'].max().date()}") + print("="*60 + "\n") + +def generar_todos_los_graficos(df): + """ + Genera todos los gráficos para cada año y cada mes disponible + """ + # Obtener años y meses únicos + años = sorted(df['año'].unique()) + + print("\n" + "="*60) + print("GENERANDO GRÁFICOS") + print("="*60) + + # Generar gráficos por año + print("\n📊 Generando gráficos anuales...") + for año in años: + print(f" - Año {año}") + top_artistas_año(df, año) + top_canciones_año(df, año) + + # Generar gráficos por mes + print("\n📊 Generando gráficos mensuales...") + for año in años: + meses_del_año = sorted(df[df['año'] == año]['mes'].unique()) + for mes in meses_del_año: + print(f" - {año}-{mes:02d}") + top_artistas_mes(df, año, mes) + top_canciones_mes(df, año, mes) + + print("\n✅ Todos los gráficos generados!") + +# EJECUCIÓN PRINCIPAL +if __name__ == "__main__": + # 1. Cargar datos + datos = cargar_archivos_json() + + # 2. Procesar datos + df = procesar_datos(datos) + + # 3. Mostrar resumen + resumen_estadisticas(df) + + # 4. Gráfico de minutos por mes + print("📊 Generando gráfico general de minutos por mes...") + minutos_mes = minutos_por_mes(df) + + # 5. Guardar CSV de Año-Mes-Minutos + print("\n💾 Guardando CSV de minutos por mes...") + minutos_mes_csv = df.groupby(['año', 'mes'])['minutos_reproducidos'].sum().reset_index() + minutos_mes_csv.columns = ['Año', 'Mes', 'Minutos'] + minutos_mes_csv = minutos_mes_csv.sort_values(['Año', 'Mes']) + minutos_mes_csv.to_csv('minutos_por_año_mes.csv', index=False, encoding='utf-8') + print("✅ CSV guardado: minutos_por_año_mes.csv") + + # 6. Generar todos los gráficos + generar_todos_los_graficos(df) + + # 7. Guardar datos procesados completos en CSV + print("\n💾 Guardando datos procesados completos en CSV...") + df.to_csv('historial_spotify_procesado.csv', index=False, encoding='utf-8') + print("✅ CSV guardado: historial_spotify_procesado.csv") + + print("\n" + "="*60) + print("🎉 ANÁLISIS COMPLETADO") + print("="*60) + print("\nArchivos generados:") + print(" 📊 minutos_por_mes.png") + print(" 📊 top_artistas_[año].png (para cada año)") + print(" 📊 top_canciones_[año].png (para cada año)") + print(" 📊 top_artistas_[año]_[mes].png (para cada mes)") + print(" 📊 top_canciones_[año]_[mes].png (para cada mes)") + print(" 📄 minutos_por_año_mes.csv") + print(" 📄 historial_spotify_procesado.csv") + print("="*60 + "\n") diff --git a/stepmania-ddr/stepmania_simplifier.py b/stepmania-ddr/stepmania_simplifier.py new file mode 100644 index 0000000..7548b95 --- /dev/null +++ b/stepmania-ddr/stepmania_simplifier.py @@ -0,0 +1,804 @@ +#!/usr/bin/env python3 +import tkinter as tk +from tkinter import ttk, filedialog, messagebox, scrolledtext +import os +import re +from typing import List, Dict, Tuple +import copy +import random + +class StepManiaSimplifier: + def __init__(self, root): + self.root = root + self.root.title("StepMania Chart Simplifier v2") + self.root.geometry("800x850") # Aumentado un poco el alto para las nuevas opciones + + self.current_file = None + self.chart_data = {} + + # Configuración para las opciones de notas + self.note_options_config = [ + {"color": "🟣", "text": "Eliminar notas púrpura (24th+) - Muy rápidas", "var_name": "24th", "default_remove": True}, + {"color": "🟢", "text": "Eliminar notas verdes (16th) - Semicorcheas", "var_name": "16th", "default_remove": True}, + {"color": "🟡", "text": "Eliminar notas amarillas (12th) - Tripletes", "var_name": "12th", "default_remove": False}, + {"color": "🔵", "text": "Eliminar notas azules (8th) - Corcheas", "var_name": "8th", "default_remove": False}, + ] + self.keep_percentage_values = ["0%", "10%", "25%", "50%", "75%"] + + self.setup_ui() + + def create_note_option_frame(self, parent, row, config): + frame = ttk.Frame(parent) + frame.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), padx=(10, 0), pady=2) + + # Variable para "Eliminar X notas" + remove_var = tk.BooleanVar(value=config["default_remove"]) + setattr(self, f"remove_{config['var_name']}", remove_var) + + # Variable para el porcentaje de "Dejar algunas" + keep_percentage_var = tk.StringVar(value=self.keep_percentage_values[0]) # Default a 0% + setattr(self, f"keep_percentage_{config['var_name']}", keep_percentage_var) + + ttk.Checkbutton(frame, text=f"{config['color']} {config['text']}", + variable=remove_var, + command=lambda vn=config['var_name']: self.toggle_keep_percentage_option(vn)).pack(side=tk.LEFT) + + ttk.Label(frame, text="Dejar algunas:").pack(side=tk.LEFT, padx=(10, 2)) + + keep_combo = ttk.Combobox(frame, textvariable=keep_percentage_var, + values=self.keep_percentage_values, + width=5, state="disabled" if not config["default_remove"] else "readonly") + keep_combo.pack(side=tk.LEFT, padx=(0, 0)) + setattr(self, f"combo_keep_percentage_{config['var_name']}", keep_combo) + + # Inicializar estado del combobox + self.toggle_keep_percentage_option(config['var_name']) + + + return frame + + def create_jump_option_frame(self, parent, row): + frame = ttk.Frame(parent) + frame.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), padx=(10, 0)) + + self.remove_jumps = tk.BooleanVar(value=False) + ttk.Checkbutton(frame, text="Eliminar saltos (notas simultáneas)", + variable=self.remove_jumps, + command=self.toggle_jump_options).pack(side=tk.LEFT) + + self.keep_some_jumps = tk.BooleanVar(value=False) # Este controla si se aplica el porcentaje + self.jump_percentage_val = tk.IntVar(value=50) # Este es el valor del slider + + self.check_jumps_some = ttk.Checkbutton(frame, text="Dejar algunos:", + variable=self.keep_some_jumps, + state="normal" if self.remove_jumps.get() else "disabled", + command=self.toggle_jump_slider_visibility) # Comando para mostrar/ocultar slider + self.check_jumps_some.pack(side=tk.LEFT, padx=(10, 0)) + + self.jump_scale = ttk.Scale(frame, from_=0, to=100, variable=self.jump_percentage_val, + orient=tk.HORIZONTAL, length=100, state="disabled") # Inicia deshabilitado + self.jump_scale.pack(side=tk.LEFT, padx=(5, 0)) + + self.jump_label = ttk.Label(frame, text="50%", state="disabled") # Inicia deshabilitado + self.jump_label.pack(side=tk.LEFT, padx=(5, 0)) + + self.jump_percentage_val.trace_add('write', self.update_jump_label) + self.toggle_jump_options() # Para setear estado inicial correcto + self.toggle_jump_slider_visibility() # Para setear estado inicial correcto del slider/label + + return frame + + def toggle_keep_percentage_option(self, var_name): + remove_var = getattr(self, f"remove_{var_name}") + combo_widget = getattr(self, f"combo_keep_percentage_{var_name}") + keep_percentage_var = getattr(self, f"keep_percentage_{var_name}") + + if remove_var.get(): + combo_widget.config(state="readonly") + else: + combo_widget.config(state="disabled") + keep_percentage_var.set("0%") # Si no se eliminan, no se dejan algunas + + def toggle_jump_options(self): + # Habilita/deshabilita el checkbox "Dejar algunos" para saltos + if self.remove_jumps.get(): + self.check_jumps_some.config(state="normal") + else: + self.check_jumps_some.config(state="disabled") + self.keep_some_jumps.set(False) # Si no se eliminan saltos, no se "dejan algunos" + self.toggle_jump_slider_visibility() # Actualiza visibilidad del slider y label + + def toggle_jump_slider_visibility(self): + # Muestra/oculta el slider y label de porcentaje de saltos + # Solo se muestran si "Eliminar saltos" Y "Dejar algunos" están activos + if self.remove_jumps.get() and self.keep_some_jumps.get(): + self.jump_scale.config(state="normal") + self.jump_label.config(state="normal") + else: + self.jump_scale.config(state="disabled") + self.jump_label.config(state="disabled") + + + def update_jump_label(self, *args): + self.jump_label.config(text=f"{self.jump_percentage_val.get()}%") + + def setup_ui(self): + main_frame = ttk.Frame(self.root, padding="10") + main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + self.root.columnconfigure(0, weight=1) + self.root.rowconfigure(0, weight=1) + main_frame.columnconfigure(1, weight=1) + + self.setup_file_selection(main_frame, 0) + self.setup_chart_selection(main_frame, 1) + self.setup_options(main_frame, 2) + self.setup_buttons(main_frame, 3) + self.setup_info_area(main_frame, 4) + self.setup_debug_area(main_frame, 5) + + def setup_file_selection(self, parent, row): + ttk.Label(parent, text="Archivo .sm:").grid(row=row, column=0, sticky=tk.W, pady=5) + + file_frame = ttk.Frame(parent) + file_frame.grid(row=row, column=1, sticky=(tk.W, tk.E), pady=5) + file_frame.columnconfigure(0, weight=1) + + self.file_var = tk.StringVar() + self.file_entry = ttk.Entry(file_frame, textvariable=self.file_var, state="readonly") + self.file_entry.grid(row=0, column=0, sticky=(tk.W, tk.E), padx=(0, 5)) + + ttk.Button(file_frame, text="Examinar", command=self.browse_file).grid(row=0, column=1) + + def setup_chart_selection(self, parent, row): + base_frame = ttk.LabelFrame(parent, text="Chart Base", padding="10") + base_frame.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=10) + base_frame.columnconfigure(1, weight=1) + + ttk.Label(base_frame, text="Seleccionar chart base:").grid(row=0, column=0, sticky=tk.W) + self.base_chart_var = tk.StringVar() + self.base_chart_combo = ttk.Combobox(base_frame, textvariable=self.base_chart_var, state="readonly") + self.base_chart_combo.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5) + + def setup_options(self, parent, row): + options_frame = ttk.LabelFrame(parent, text="Opciones de Simplificación", padding="10") + options_frame.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=10) + options_frame.columnconfigure(1, weight=1) + + current_row = 0 + + ttk.Label(options_frame, text="Eliminar notas rápidas:", font=('TkDefaultFont', 9, 'bold')).grid( + row=current_row, column=0, columnspan=2, sticky=tk.W, pady=(0, 5)) + current_row += 1 + + for config in self.note_options_config: + self.create_note_option_frame(options_frame, current_row, config) + current_row +=1 + + ttk.Separator(options_frame, orient='horizontal').grid(row=current_row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=10) + current_row += 1 + + ttk.Label(options_frame, text="Simplificar patrones:", font=('TkDefaultFont', 9, 'bold')).grid( + row=current_row, column=0, columnspan=2, sticky=tk.W, pady=(0, 5)) + current_row += 1 + + self.create_jump_option_frame(options_frame, current_row) + current_row += 1 + + self.simplify_holds = tk.BooleanVar(value=False) + ttk.Checkbutton(options_frame, text="Convertir holds largos en notas normales", + variable=self.simplify_holds).grid(row=current_row, column=0, columnspan=2, sticky=tk.W, padx=(10, 0)) + current_row += 1 + + ttk.Separator(options_frame, orient='horizontal').grid(row=current_row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=10) + current_row += 1 + + ttk.Label(options_frame, text="Nuevo chart:", font=('TkDefaultFont', 9, 'bold')).grid( + row=current_row, column=0, columnspan=2, sticky=tk.W, pady=(0, 5)) + current_row += 1 + + ttk.Label(options_frame, text="Nombre nueva dificultad:").grid(row=current_row, column=0, sticky=tk.W, padx=(10, 0)) + self.new_difficulty_name = tk.StringVar(value="Easy") + ttk.Entry(options_frame, textvariable=self.new_difficulty_name, width=15).grid(row=current_row, column=1, sticky=tk.W, padx=5) + + def setup_buttons(self, parent, row): + button_frame = ttk.Frame(parent) + button_frame.grid(row=row, column=0, columnspan=2, pady=10) + + # El botón de Analizar ya no es necesario aquí, se hace al cargar + # ttk.Button(button_frame, text="Analizar Archivo", command=self.analyze_file).pack(side=tk.LEFT, padx=5) + ttk.Button(button_frame, text="Generar Versión Simplificada", command=self.generate_simplified).pack(side=tk.LEFT, padx=5) + + def setup_info_area(self, parent, row): + info_frame = ttk.LabelFrame(parent, text="Información del Archivo", padding="10") + info_frame.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S), pady=10) + info_frame.columnconfigure(0, weight=1) + info_frame.rowconfigure(0, weight=1) + parent.rowconfigure(row, weight=1) + + self.info_text = scrolledtext.ScrolledText(info_frame, height=10, width=70, wrap=tk.WORD) # wrap=tk.WORD + self.info_text.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + def setup_debug_area(self, parent, row): + debug_frame = ttk.LabelFrame(parent, text="Debug Output", padding="10") + debug_frame.grid(row=row, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=10) + debug_frame.columnconfigure(0, weight=1) + + self.debug_text = scrolledtext.ScrolledText(debug_frame, height=5, width=70, wrap=tk.WORD) # wrap=tk.WORD + self.debug_text.grid(row=0, column=0, sticky=(tk.W, tk.E)) + + def browse_file(self): + filename = filedialog.askopenfilename( + title="Seleccionar archivo .sm", + filetypes=[("StepMania files", "*.sm"), ("All files", "*.*")] + ) + if filename: + self.file_var.set(filename) + self.current_file = filename + self.analyze_file() # <--- ANÁLISIS AUTOMÁTICO + + def parse_sm_file(self, filepath: str) -> Dict: + # Intenta con 'utf-8' primero, que es lo más común y robusto + encodings_to_try = ['utf-8', 'cp1252', 'iso-8859-1', 'latin1'] + content = None + + for encoding in encodings_to_try: + try: + with open(filepath, 'r', encoding=encoding) as f: + content = f.read() + self.debug_print(f"Archivo leído exitosamente con encoding: {encoding}") + break + except UnicodeDecodeError: + self.debug_print(f"Fallo al leer con encoding: {encoding}") + continue + except Exception as e: + self.debug_print(f"Error inesperado al abrir el archivo con {encoding}: {e}") + continue + + if content is None: + # Si todos fallan, intenta con errors='ignore' como último recurso + try: + with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + self.debug_print("Archivo leído con encoding utf-8 e ignorando errores.") + except Exception as e: + messagebox.showerror("Error de Lectura", f"No se pudo leer el archivo '{os.path.basename(filepath)}' con los encodings probados.\n{e}") + return {} + + + data = {} + patterns = { + 'title': r'#TITLE:([^;]+);', + 'artist': r'#ARTIST:([^;]+);', + 'bpms': r'#BPMS:([^;]+);', + 'offset': r'#OFFSET:([^;]+);' + } + + for key, pattern in patterns.items(): + match = re.search(pattern, content, re.IGNORECASE) + data[key] = match.group(1).strip() if match else 'Unknown' + + charts = [] + # Patrón mejorado para ser más tolerante con espacios y saltos de línea + chart_pattern = r'#NOTES:\s*([^:]*):\s*([^:]*):\s*([^:]*):\s*([^:]*):\s*([^:]*):\s*([^;]+);' + + for match in re.finditer(chart_pattern, content, re.DOTALL | re.IGNORECASE): + chart = { + 'type': match.group(1).strip(), + 'description': match.group(2).strip(), # Author/Description + 'difficulty': match.group(3).strip(), # Difficulty Name + 'level': match.group(4).strip(), # Difficulty Meter + 'radar': match.group(5).strip().replace('\n','').replace('\r','').replace(' ',''), # Radar values + 'notes': match.group(6).strip() # Note data + } + charts.append(chart) + + data['charts'] = charts + data['original_content'] = content + return data + + def analyze_file(self): + if not self.current_file: + # Esto no debería ocurrir si se llama desde browse_file, pero por si acaso + messagebox.showerror("Error", "Por favor selecciona un archivo .sm primero") + return + + self.info_text.delete(1.0, tk.END) # Limpiar info anterior + self.debug_text.delete(1.0, tk.END) # Limpiar debug anterior + self.base_chart_combo['values'] = [] # Limpiar combobox de charts + self.base_chart_var.set("") + + try: + self.debug_print(f"Analizando archivo: {self.current_file}") + self.chart_data = self.parse_sm_file(self.current_file) + if not self.chart_data: # Si parse_sm_file devolvió vacío por error + return + self.display_file_info() + self.debug_print("Análisis completado.") + except Exception as e: + messagebox.showerror("Error", f"Error al analizar el archivo: {str(e)}") + self.debug_print(f"Excepción en analyze_file: {str(e)}") + + + def display_file_info(self): + info = f"=== INFORMACIÓN DEL ARCHIVO ===\n" + info += f"Título: {self.chart_data.get('title', 'N/A')}\n" + info += f"Artista: {self.chart_data.get('artist', 'N/A')}\n" + info += f"BPMs: {self.chart_data.get('bpms', 'N/A')}\n" + info += f"Offset: {self.chart_data.get('offset', 'N/A')}\n\n" + + info += "=== CHARTS DISPONIBLES ===\n" + chart_options = [] + + if not self.chart_data.get('charts'): + info += "No se encontraron charts válidos en el archivo.\n" + self.debug_print("No se encontraron charts en los datos parseados.") + else: + for i, chart in enumerate(self.chart_data.get('charts', [])): + chart_name = f"{chart.get('difficulty','UnknownDif')} (Lv.{chart.get('level','?')}) - {chart.get('description','NoDesc')}" + chart_options.append(chart_name) + + info += f"{i+1}. {chart_name}\n" + info += f" Tipo: {chart.get('type','N/A')}\n" + + notes_analysis = self.analyze_notes_summary(chart['notes']) + info += f" Notas totales: {notes_analysis['total_notes']}\n" + info += f" Saltos: {notes_analysis['jumps']}\n" + info += f" Holds: {notes_analysis['holds']}\n" + info += f" Minas: {notes_analysis['mines']}\n" + info += f" Compases estimados: {notes_analysis['measures']}\n\n" + + + self.base_chart_combo['values'] = chart_options + if chart_options: + self.base_chart_combo.current(0) + else: + self.base_chart_var.set("No hay charts disponibles") + + + self.info_text.delete(1.0, tk.END) + self.info_text.insert(1.0, info) + + def analyze_notes_summary(self, notes_data: str) -> Dict: + lines = [line.strip() for line in notes_data.split('\n') if line.strip()] + + total_notes = 0 + jumps = 0 + holds_start = 0 # '2' + mines = 0 + measures = 0 + + for line in lines: + if line.startswith(','): + measures += 1 + continue + + # Solo procesar líneas que parecen ser de notas (longitud y caracteres) + if len(line) >= 4 and all(c in '01234MFLK' for c in line[:4].upper()): # Simplificado + note_count_in_line = 0 + for char_idx, char_val in enumerate(line[:4]): + if char_val == '1': # Tap note + total_notes += 1 + note_count_in_line +=1 + elif char_val == '2': # Hold start + total_notes += 1 + holds_start += 1 + note_count_in_line +=1 + elif char_val == '4': # Roll start (contar como hold) + total_notes += 1 + holds_start += 1 + note_count_in_line +=1 + elif char_val.upper() == 'M': # Mine + mines +=1 + + if note_count_in_line > 1: + jumps +=1 + + return { + 'total_notes': total_notes, + 'jumps': jumps, + 'holds': holds_start, + 'mines': mines, + 'measures': measures + } + + def calculate_difficulty_level(self, original_level: int, notes_removed_percentage: float) -> int: + if not isinstance(original_level, int) or original_level < 1: + original_level = 1 # Default a 1 si el original no es válido + + # Reducción más pronunciada si se quitan muchas notas, menos si se quitan pocas. + # Por ejemplo, quitar 50% de notas podría reducir el nivel a la mitad. + # Quitar 10% de notas podría reducir el nivel un 10-20%. + difficulty_reduction_factor = notes_removed_percentage * 0.7 # Ajustar este factor según se vea + + new_level = original_level * (1 - difficulty_reduction_factor) + + # Asegurar que el nivel no baje de 1 y redondear + new_level = max(1, round(new_level)) + + return new_level + + def simplify_chart(self, chart: Dict) -> Dict: + simplified_chart = copy.deepcopy(chart) + notes_lines = chart['notes'].split('\n') + + simplified_lines = [] + current_measure_lines = [] + notes_removed_count = 0 + original_tap_and_hold_notes_count = 0 # Contar solo '1' y '2' para el % de reducción + + # Primera pasada para contar notas originales relevantes + for line_idx, line_content in enumerate(notes_lines): + stripped_line = line_content.strip() + if stripped_line == ',': + continue + if len(stripped_line) >= 4 and self.is_valid_note_line(stripped_line): + for char_note in stripped_line[:4]: + if char_note in '124': # Tap, Hold start, Roll start + original_tap_and_hold_notes_count += 1 + + self.debug_print(f"Notas originales (tap/hold/roll): {original_tap_and_hold_notes_count}") + + + for line in notes_lines: + stripped_line = line.strip() + if not stripped_line: # Línea vacía + if current_measure_lines: # Procesar compás acumulado si existe + processed_measure, removed_in_measure = self.process_measure(current_measure_lines) + simplified_lines.extend(processed_measure) + notes_removed_count += removed_in_measure + current_measure_lines = [] + simplified_lines.append(line) # Añadir la línea vacía + continue + + if stripped_line == ',': + if current_measure_lines: + processed_measure, removed_in_measure = self.process_measure(current_measure_lines) + simplified_lines.extend(processed_measure) + notes_removed_count += removed_in_measure + current_measure_lines = [] + simplified_lines.append(line) # Añadir la coma + continue + + if self.is_valid_note_line(stripped_line): + current_measure_lines.append(line) # Usar línea original con sus espacios + else: # Líneas que no son de notas (comentarios, etc.) + if current_measure_lines: # Procesar compás acumulado si lo hubiera antes de esta línea no-nota + processed_measure, removed_in_measure = self.process_measure(current_measure_lines) + simplified_lines.extend(processed_measure) + notes_removed_count += removed_in_measure + current_measure_lines = [] + simplified_lines.append(line) + + + if current_measure_lines: # Procesar el último compás si queda algo + processed_measure, removed_in_measure = self.process_measure(current_measure_lines) + simplified_lines.extend(processed_measure) + notes_removed_count += removed_in_measure + + simplified_chart['notes'] = '\n'.join(simplified_lines) + + try: + original_level = int(chart['level']) + except ValueError: + self.debug_print(f"Nivel original '{chart['level']}' no es un número. Usando 5 por defecto.") + original_level = 5 # Default si el nivel no es un número + + notes_removed_percentage = 0 + if original_tap_and_hold_notes_count > 0: + notes_removed_percentage = notes_removed_count / original_tap_and_hold_notes_count + + self.debug_print(f"Notas eliminadas: {notes_removed_count}") + self.debug_print(f"Porcentaje de notas eliminadas: {notes_removed_percentage:.2%}") + + new_level = self.calculate_difficulty_level(original_level, notes_removed_percentage) + + simplified_chart['difficulty'] = self.new_difficulty_name.get() + simplified_chart['level'] = str(new_level) + + base_desc = chart['description'] + if "(Simplified)" not in base_desc and "(Easy)" not in base_desc: # Evitar duplicados + simplified_chart['description'] = f"{base_desc} ({self.new_difficulty_name.get()})" + else: # Si ya tiene un tag de simplificación, lo reemplazamos o usamos el nuevo + parts = re.split(r'\s*\(.*\)', base_desc) # Quitar el (tag) viejo + simplified_chart['description'] = f"{parts[0].strip()} ({self.new_difficulty_name.get()})" + + + return simplified_chart + + + def is_valid_note_line(self, line: str) -> bool: + # Una línea de nota válida tiene al menos 4 caracteres y esos son 0-4, M, F, L, K + # (considerando mayúsculas para MFLK) + if len(line) < 4: + return False + return all(c in '01234MFLK' for c in line[:4].upper()) + + + def process_measure(self, measure_lines: List[str]) -> Tuple[List[str], int]: + if not measure_lines: + return [], 0 + + processed_lines = [] + notes_removed_in_measure = 0 + + num_lines_in_measure = len(measure_lines) + + for i, original_line_text in enumerate(measure_lines): + line_text = original_line_text.strip() # Trabajar con la línea sin espacios extra al inicio/fin + + # Mantener los espacios originales del inicio para el formato + leading_whitespace = "" + match_whitespace = re.match(r"(\s*)", original_line_text) + if match_whitespace: + leading_whitespace = match_whitespace.group(1) + + modified_line_chars = list(line_text[:4]) # Solo los primeros 4 caracteres para notas + rest_of_line = line_text[4:] # Comentarios, etc. + + # 1. Detección de subdivisión y eliminación de notas rápidas + subdivision = self.detect_note_subdivision_in_measure(i, num_lines_in_measure) + + option_var_name = None + if subdivision == "24th+": option_var_name = "24th" + elif subdivision == "16th": option_var_name = "16th" + elif subdivision == "12th": option_var_name = "12th" + elif subdivision == "8th": option_var_name = "8th" + + line_had_notes = any(c in '124' for c in modified_line_chars) + + if option_var_name: + remove_this_subdivision = getattr(self, f"remove_{option_var_name}").get() + if remove_this_subdivision: + keep_percentage_str = getattr(self, f"keep_percentage_{option_var_name}").get() + keep_percentage = float(keep_percentage_str.replace('%','')) / 100.0 + + if random.random() >= keep_percentage: # Si random es MAYOR o IGUAL, se elimina + for k_idx, k_char in enumerate(modified_line_chars): + if k_char in '124': # Tap, Hold, Roll + modified_line_chars[k_idx] = '0' + notes_removed_in_measure += 1 + # self.debug_print(f"L{i} ({subdivision}) eliminada (o parte), %keep: {keep_percentage_str}") + # else: + # self.debug_print(f"L{i} ({subdivision}) MANTENIDA por % ({keep_percentage_str})") + + + # 2. Simplificación de saltos (después de posible eliminación por subdivisión) + current_notes_in_line = sum(1 for char_note in modified_line_chars if char_note in '124') + if current_notes_in_line > 1 and self.remove_jumps.get(): + # self.debug_print(f"Salto detectado en L{i}: {''.join(modified_line_chars)}") + notes_indices_in_jump = [idx for idx, char_note in enumerate(modified_line_chars) if char_note in '124'] + + notes_to_keep_count = 1 # Por defecto, dejar 1 nota de un salto + if self.keep_some_jumps.get(): # Si el checkbox "Dejar algunos [saltos]" está activo + percentage_to_keep_jumps = self.jump_percentage_val.get() / 100.0 + # self.debug_print(f" Intentando mantener {percentage_to_keep_jumps*100}% de {current_notes_in_line} notas del salto") + notes_to_keep_count = max(1, int(round(len(notes_indices_in_jump) * percentage_to_keep_jumps))) + + # self.debug_print(f" Original: {notes_indices_in_jump}, a mantener: {notes_to_keep_count}") + + # Mantener las primeras 'notes_to_keep_count' notas, eliminar el resto + random.shuffle(notes_indices_in_jump) # Aleatorizar cuáles se quedan + notes_to_remove_from_jump = notes_indices_in_jump[notes_to_keep_count:] + + for idx_to_remove in notes_to_remove_from_jump: + if modified_line_chars[idx_to_remove] in '124': + modified_line_chars[idx_to_remove] = '0' + notes_removed_in_measure += 1 + # self.debug_print(f" Salto simplificado L{i}: {''.join(modified_line_chars)}") + + + # 3. Simplificación de holds (después de todo lo anterior) + if self.simplify_holds.get(): + for k_idx, k_char in enumerate(modified_line_chars): + if k_char == '2': # Inicio de Hold + modified_line_chars[k_idx] = '1' # Convertir a Tap + # No contamos esto como nota eliminada, es una conversión + elif k_char == '3': # Fin de Hold + modified_line_chars[k_idx] = '0' # Eliminar marcador de fin + elif k_char == '4': # Inicio de Roll + modified_line_chars[k_idx] = '1' # Convertir a Tap + # No se tocan 'L' (fin de roll) ya que no tienen valor numérico en StepMania y son más raros + + processed_lines.append(leading_whitespace + "".join(modified_line_chars) + rest_of_line) + + return processed_lines, notes_removed_in_measure + + + def detect_note_subdivision_in_measure(self, line_index: int, total_lines_in_measure: int) -> str: + """ + Detecta la subdivisión de una nota basada en su índice dentro del compás y el total de líneas. + Esta es una heurística y puede no ser perfecta para todos los casos de BPM changes o time signatures. + Asume que las líneas de notas están distribuidas uniformemente dentro del compás. + """ + if total_lines_in_measure == 0: return "Unknown" + + # Casos comunes para 4/4 time signature + if total_lines_in_measure % 48 == 0: # Probablemente 48ths (o 24ths si son pares) + beat_division = 48 + elif total_lines_in_measure % 32 == 0: # Probablemente 32nds + beat_division = 32 + elif total_lines_in_measure % 24 == 0: # Probablemente 24ths + beat_division = 24 + elif total_lines_in_measure % 16 == 0: # Probablemente 16ths + beat_division = 16 + elif total_lines_in_measure % 12 == 0: # Probablemente 12ths (triplets over 4th) + beat_division = 12 + elif total_lines_in_measure % 8 == 0: # Probablemente 8ths + beat_division = 8 + elif total_lines_in_measure % 6 == 0: # Probablemente 6ths (triplets over 8th, raro pero posible) + beat_division = 6 + elif total_lines_in_measure % 4 == 0: # Probablemente 4ths + beat_division = 4 + elif total_lines_in_measure % 3 == 0 and total_lines_in_measure <= 12 : # Podría ser un compás de 3/4 en 4ths, o 4/4 en 3 notas por alguna razón + beat_division = total_lines_in_measure # ej. 3 notas: 3rd, 6 notas: 6th + elif total_lines_in_measure % 2 == 0 and total_lines_in_measure <= 8: + beat_division = total_lines_in_measure + else: # Casos menos comunes o compases con pocas notas + if total_lines_in_measure > 16 : return "24th+" # Si hay muchas, default a muy rápidas + if total_lines_in_measure > 12 : return "16th" + if total_lines_in_measure > 8 : return "12th" + if total_lines_in_measure > 4 : return "8th" + return "4th" + + # Simplificación de la lógica de subdivisión + # El `line_index` nos dice en qué "slot" de la subdivisión más fina cae esta línea. + # Si `total_lines_in_measure` es 16 (16ths), y `line_index` es 0, 4, 8, 12, es un 4th. + # Si es 2, 6, 10, 14, es un 8th (pero no 4th). + # El resto son 16ths. + + if line_index % (beat_division / 4) == 0: return "4th" + if beat_division >= 8 and line_index % (beat_division / 8) == 0: return "8th" + if beat_division >= 12 and line_index % (beat_division / 12) == 0: return "12th" + if beat_division >= 16 and line_index % (beat_division / 16) == 0: return "16th" + if beat_division >= 24 : return "24th+" # Cubre 24th, 32nd, 48th, etc. + + return "Unknown" # Default por si acaso + + + def debug_print(self, message): + if hasattr(self, 'debug_text') and self.debug_text: + try: + self.debug_text.insert(tk.END, f"{message}\n") + self.debug_text.see(tk.END) + self.root.update_idletasks() # Forzar actualización de la UI + except tk.TclError: # En caso de que el widget ya no exista (ej. al cerrar) + pass + print(f"DEBUG: {message}") # También imprimir a consola + + + def remove_jump_notes(self, line: str) -> str: + # Esta función ya no se usa directamente, su lógica está en process_measure + # Se deja aquí por si se quiere reusar o como referencia, pero no es llamada. + if len(line) < 4: + return line + + chars = list(line) + jump_notes_indices = [i for i, char_note in enumerate(chars[:4]) if char_note in '124'] + + if len(jump_notes_indices) > 1: # Es un salto + if self.remove_jumps.get(): # Si la opción general de eliminar saltos está activa + notes_to_keep_count = 1 # Por defecto, dejar 1 nota + + if self.keep_some_jumps.get(): # Si el checkbox "Dejar algunos [saltos]" está activo + percentage_to_keep = self.jump_percentage_val.get() / 100.0 + notes_to_keep_count = max(1, int(round(len(jump_notes_indices) * percentage_to_keep))) + + # Aleatorizar y seleccionar las notas a mantener + random.shuffle(jump_notes_indices) + notes_to_remove_indices = jump_notes_indices[notes_to_keep_count:] + + for idx_to_remove in notes_to_remove_indices: + chars[idx_to_remove] = '0' + + return "".join(chars) + + + def simplify_hold_notes(self, line: str) -> str: + # Esta función ya no se usa directamente, su lógica está en process_measure + modified_line = list(line) + for i, char_val in enumerate(modified_line[:4]): + if char_val == '2': # Hold Start + modified_line[i] = '1' + elif char_val == '3': # Hold End + modified_line[i] = '0' + elif char_val == '4': # Roll Start + modified_line[i] = '1' + return "".join(modified_line) + + + def generate_simplified(self): + if not self.chart_data or not self.chart_data.get('charts'): + messagebox.showerror("Error", "Por favor carga y analiza un archivo primero, o el archivo no contiene charts.") + return + + if not self.base_chart_var.get() or "No hay charts" in self.base_chart_var.get(): + messagebox.showerror("Error", "Por favor selecciona un chart base válido.") + return + + try: + selected_index = self.base_chart_combo.current() + if selected_index < 0 or selected_index >= len(self.chart_data['charts']): + messagebox.showerror("Error", "Chart seleccionado no válido. Intenta recargar el archivo.") + return + + base_chart = self.chart_data['charts'][selected_index] + self.debug_print(f"Generando versión simplificada para: {base_chart.get('difficulty')} (Lv.{base_chart.get('level')})") + + simplified_chart = self.simplify_chart(base_chart) + + # Contar notas '1' (tap) en el chart simplificado para una verificación rápida + simplified_tap_notes_count = 0 + for line_s in simplified_chart['notes'].split('\n'): + if self.is_valid_note_line(line_s.strip()): + for char_s in line_s.strip()[:4]: + if char_s == '1': + simplified_tap_notes_count +=1 + + self.debug_print(f"Chart simplificado: {simplified_tap_notes_count} notas '1' (tap) finales.") + self.debug_print(f"Nivel original: {base_chart['level']}, Nivel calculado: {simplified_chart['level']}") + + if simplified_tap_notes_count == 0: # Chequeo más específico + if not messagebox.askyesno("Advertencia", + "El chart simplificado no tiene notas '1' (tap notes).\n" + "Esto puede resultar en un chart vacío o no jugable.\n" + "¿Deseas generarlo de todas formas?"): + self.debug_print("Generación cancelada por el usuario debido a 0 notas tap.") + return + + new_content = self.chart_data['original_content'] + + # Asegurarse de que el nuevo bloque de notas se añade al final del archivo + if not new_content.endswith('\n\n'): + if new_content.endswith('\n'): + new_content += '\n' + else: + new_content += '\n\n' + + # Formato del bloque de notas + # //---------------dance-single - [Simplificado (Easy)]---------------- + comment_desc = simplified_chart['description'].replace(':','-') # Evitar problemas con ':' en comentarios + comment = f"//---------------{simplified_chart['type']} - {comment_desc}----------------\n" + + new_notes_block = comment + new_notes_block += "#NOTES:\n" + new_notes_block += f" {simplified_chart['type']}:\n" + new_notes_block += f" {simplified_chart['description']}:\n" # Ya incluye el (Simplified) o (Easy) + new_notes_block += f" {simplified_chart['difficulty']}:\n" + new_notes_block += f" {simplified_chart['level']}:\n" + new_notes_block += f" {simplified_chart['radar']}:\n" # Usar el radar original + new_notes_block += f"{simplified_chart['notes']};\n\n" + + new_content += new_notes_block + + original_path = self.current_file + base_name = os.path.splitext(original_path)[0] + + # Construir nombre de archivo con tag de dificultad si es posible + difficulty_tag = self.new_difficulty_name.get().replace(" ", "_") + new_path = f"{base_name}_{difficulty_tag}_simplified.sm" + + # Intentar guardar con el encoding original si es conocido, sino utf-8 + source_encoding = 'utf-8' # Default + # (No tenemos una forma fácil de saber el encoding original exacto después de leerlo) + + with open(new_path, 'w', encoding=source_encoding, errors='ignore') as f: + f.write(new_content) + + messagebox.showinfo("Éxito", f"Archivo simplificado guardado como:\n{new_path}") + self.debug_print(f"Archivo simplificado guardado en: {new_path}") + + except Exception as e: + messagebox.showerror("Error", f"Error al generar archivo simplificado: {str(e)}") + self.debug_print(f"Excepción en generate_simplified: {str(e)}") + import traceback + self.debug_print(traceback.format_exc()) + + +def main(): + root = tk.Tk() + app = StepManiaSimplifier(root) + root.mainloop() + +if __name__ == "__main__": + main() diff --git a/stepmania-ddr/stepmania_sm_generator.py b/stepmania-ddr/stepmania_sm_generator.py new file mode 100644 index 0000000..06f0e7e --- /dev/null +++ b/stepmania-ddr/stepmania_sm_generator.py @@ -0,0 +1,1208 @@ +#!/usr/bin/env python3 +""" +StepMania .sm File Generator +============================= +Analyzes audio files and generates StepMania DDR step charts (.sm) +with multiple difficulty levels (Beginner → Challenge). + +Uses audio analysis for step-chart generation: + - BPM detection via tempo estimation (madmom neural net or librosa fallback) + - Beat & downbeat tracking via DBN / accent-pattern analysis + - Onset detection via spectral flux + - Spectral band analysis for musically-aware arrow placement + - Optional background video conversion to MP4 (H.264, no audio) + +Requirements: + pip install librosa numpy soundfile + pip install madmom # optional but STRONGLY recommended for accuracy (Install using git madmom repo if Python > 3.9) + System: ffmpeg (for audio/video decoding + conversion) +""" + +import tkinter as tk +from tkinter import ttk, filedialog, messagebox +import threading +import os +import sys +import traceback +import subprocess +import shutil +import numpy as np +import random +from pathlib import Path + +try: + import librosa +except ImportError: + print("ERROR: librosa is required. Install with: pip install librosa numpy soundfile") + sys.exit(1) + +# madmom: optional but MUCH more accurate for BPM + downbeat detection +HAS_MADMOM = False +try: + import madmom + import madmom.features.beats + import madmom.features.downbeats + import madmom.features.tempo + HAS_MADMOM = True +except ImportError: + pass + + +# ────────────────────────────────────────────────────────────────────────────── +# Audio Conversion Helper +# ────────────────────────────────────────────────────────────────────────────── + +def convert_to_mp3(input_path: str, callback=None) -> str: + """Convert audio file to MP3 if it's not already MP3. + + Returns the path to the MP3 file (original if already MP3, converted otherwise). + """ + cb = callback or (lambda msg, pct: None) + + input_path = Path(input_path) + if input_path.suffix.lower() == '.mp3': + cb("Audio is already MP3, no conversion needed", 3) + return str(input_path) + + # Check if ffmpeg is available + if not shutil.which('ffmpeg'): + raise RuntimeError( + "ffmpeg is required to convert audio to MP3.\n" + "Install it with: sudo apt install ffmpeg (Linux) or brew install ffmpeg (macOS)" + ) + + output_path = input_path.with_suffix('.mp3') + cb(f"Converting {input_path.suffix} to MP3...", 2) + + try: + # Use ffmpeg to convert to MP3 with good quality settings + result = subprocess.run( + [ + 'ffmpeg', '-i', str(input_path), + '-codec:a', 'libmp3lame', + '-qscale:a', '2', # High quality (VBR ~190 kbps) + '-y', # Overwrite if exists + str(output_path) + ], + capture_output=True, + text=True, + timeout=300 + ) + + if result.returncode != 0: + raise RuntimeError(f"ffmpeg conversion failed: {result.stderr}") + + if not output_path.exists(): + raise RuntimeError("MP3 file was not created") + + cb(f"Converted to MP3: {output_path.name}", 4) + return str(output_path) + + except subprocess.TimeoutExpired: + raise RuntimeError("Audio conversion timed out (5 min limit)") + except FileNotFoundError: + raise RuntimeError("ffmpeg not found. Please install ffmpeg.") + + +def convert_to_mp4_video(input_path: str, callback=None) -> str: + """Convert a video file to MP4 (H.264) with no audio. + + Returns the path to the MP4 file (original if already compatible). + """ + cb = callback or (lambda msg, pct: None) + + input_path = Path(input_path) + if not input_path.exists(): + raise RuntimeError(f"Video file not found: {input_path}") + + if not shutil.which('ffmpeg'): + raise RuntimeError( + "ffmpeg is required to convert video to MP4.\n" + "Install it with: sudo apt install ffmpeg (Linux) or brew install ffmpeg (macOS)" + ) + + def _is_compatible_mp4(path: Path) -> bool: + if path.suffix.lower() != '.mp4': + return False + if not shutil.which('ffprobe'): + cb("ffprobe not found; converting video to be safe", 3) + return False + try: + probe = subprocess.run( + [ + 'ffprobe', '-v', 'error', + '-select_streams', 'v:0', + '-show_entries', 'stream=codec_name', + '-of', 'default=nokey=1:noprint_wrappers=1', + str(path) + ], + capture_output=True, + text=True, + timeout=15 + ) + vcodec = probe.stdout.strip() if probe.returncode == 0 else '' + + probe_a = subprocess.run( + [ + 'ffprobe', '-v', 'error', + '-select_streams', 'a', + '-show_entries', 'stream=codec_name', + '-of', 'default=nokey=1:noprint_wrappers=1', + str(path) + ], + capture_output=True, + text=True, + timeout=15 + ) + has_audio = bool(probe_a.stdout.strip()) + + return vcodec == 'h264' and not has_audio + except Exception: + cb("ffprobe error; converting video to be safe", 3) + return False + + if _is_compatible_mp4(input_path): + cb("Video already MP4 (H.264) with no audio, no conversion needed", 3) + return str(input_path) + + if input_path.suffix.lower() == '.mp4': + output_path = input_path.with_name(f"{input_path.stem}_smgen.mp4") + else: + output_path = input_path.with_suffix('.mp4') + + cb(f"Converting video to MP4 (H.264, no audio): {input_path.name} …", 2) + + try: + result = subprocess.run( + [ + 'ffmpeg', '-i', str(input_path), + '-map', '0:v:0', + '-c:v', 'libx264', + '-preset', 'medium', + '-crf', '18', + '-pix_fmt', 'yuv420p', + '-an', + '-movflags', '+faststart', + '-y', + str(output_path) + ], + capture_output=True, + text=True, + timeout=600 + ) + + if result.returncode != 0: + raise RuntimeError(f"ffmpeg conversion failed: {result.stderr}") + + if not output_path.exists(): + raise RuntimeError("MP4 file was not created") + + cb(f"Converted video: {output_path.name}", 4) + return str(output_path) + + except subprocess.TimeoutExpired: + raise RuntimeError("Video conversion timed out (10 min limit)") + except FileNotFoundError: + raise RuntimeError("ffmpeg not found. Please install ffmpeg.") + + +# ────────────────────────────────────────────────────────────────────────────── +# Audio Analysis +# ────────────────────────────────────────────────────────────────────────────── + +class AudioAnalyzer: + """Extracts musical features from an audio file for step-chart generation.""" + + def __init__(self, filepath: str, callback=None, bpm_override: float = None): + self.filepath = filepath + self._cb = callback or (lambda msg, pct: None) + self.bpm_override = bpm_override + self.y = None + self.sr = 22050 + self.duration = 0.0 + self.bpm = 120.0 + self.beat_times = np.array([]) + self.onset_times = np.array([]) + self.onset_strengths = np.array([]) + self.mel_spec = None + self.n_mels = 128 + self.music_start = 0.0 # time (s) when music actually begins + self.first_downbeat = 0.0 # time (s) of the first aligned downbeat + self.rms = None # RMS energy envelope + + # -- helpers -- + def _log(self, msg, pct=0): + self._cb(msg, pct) + + # -- pipeline steps -- + def load_audio(self): + self._log("Loading audio file …", 5) + try: + self.y, self.sr = librosa.load(self.filepath, sr=self.sr, mono=True) + except Exception as e: + raise RuntimeError( + f"Cannot load audio. Is ffmpeg installed?\n{e}" + ) + self.duration = librosa.get_duration(y=self.y, sr=self.sr) + self._log(f"Loaded: {self.duration:.1f}s SR={self.sr} Hz", 10) + + def compute_mel_spectrogram(self): + self._log("Computing mel spectrogram …", 15) + S = librosa.feature.melspectrogram( + y=self.y, sr=self.sr, n_mels=self.n_mels, fmax=8000 + ) + self.mel_spec = librosa.power_to_db(S, ref=np.max) + self._log("Mel spectrogram ready", 25) + + def detect_music_start(self): + """Find the actual start of music by detecting when RMS energy + exceeds a meaningful threshold. Avoids false beats in silence.""" + self._log("Detecting music start …", 28) + # Compute RMS in short frames + self.rms = librosa.feature.rms(y=self.y, frame_length=2048, hop_length=512)[0] + rms_times = librosa.frames_to_time( + np.arange(len(self.rms)), sr=self.sr, hop_length=512 + ) + + # Threshold: 5% of the peak RMS (catches soft intros but ignores noise) + peak_rms = np.max(self.rms) + threshold = peak_rms * 0.05 + + # Find the first frame that exceeds the threshold + above = np.where(self.rms > threshold)[0] + if len(above) > 0: + self.music_start = float(rms_times[above[0]]) + else: + self.music_start = 0.0 + + # Small safety margin: step back 50ms so we don't clip the attack + self.music_start = max(0.0, self.music_start - 0.05) + self._log(f"Music starts at {self.music_start:.3f}s", 30) + + def _estimate_bpm_multimethod(self): + """Estimate BPM using multiple methods and pick the best candidate. + + librosa.beat.beat_track often doubles or mis-detects BPM on + syncopated genres (reggaeton, trap, …). We cross-check with + onset-autocorrelation and spectral-flux tempogram to find the + true tempo. + """ + self._log(" Method 1: beat_track …", 33) + onset_env = librosa.onset.onset_strength(y=self.y, sr=self.sr) + + # --- Method 1: default beat_track --- + tempo1, _ = librosa.beat.beat_track(y=self.y, sr=self.sr, + onset_envelope=onset_env) + t1 = float(tempo1[0]) if hasattr(tempo1, '__len__') else float(tempo1) + + # --- Method 2: beat_track with alternative start_bpm prior --- + # librosa's beat_track uses a Bayesian prior centred on start_bpm + # (default 120). Running again with start_bpm=95 biases toward + # the 80-110 range common in reggaeton / trap / hip-hop / latin + # and acts as a cross-check to catch tempo-doubling errors. + self._log(" Method 2: beat_track (start_bpm=95) …", 34) + tempo2, _ = librosa.beat.beat_track(y=self.y, sr=self.sr, + onset_envelope=onset_env, + start_bpm=95) + t2 = float(tempo2[0]) if hasattr(tempo2, '__len__') else float(tempo2) + + # --- Method 3: tempogram autocorrelation (gives multiple peaks) --- + self._log(" Method 3: tempo via tempogram …", 35) + tempo3 = librosa.feature.tempo( + onset_envelope=onset_env, sr=self.sr, aggregate=None + ) + # tempo3 is an array of one or more candidates + t3_candidates = [float(t) for t in np.atleast_1d(tempo3) if 40 < float(t) < 240] + + # --- Method 4: onset-autocorrelation on percussive component --- + self._log(" Method 4: percussive onset autocorrelation …", 36) + y_perc = librosa.effects.percussive(self.y, margin=3.0) + onset_perc = librosa.onset.onset_strength(y=y_perc, sr=self.sr) + tempo4, _ = librosa.beat.beat_track(y=y_perc, sr=self.sr, + onset_envelope=onset_perc) + t4 = float(tempo4[0]) if hasattr(tempo4, '__len__') else float(tempo4) + + # --- Collect all raw candidates --- + raw = [t1, t2, t4] + t3_candidates + self._log(f" Raw candidates: {[f'{t:.1f}' for t in raw]}", 37) + + # --- Generate octave variants for each raw candidate --- + candidates = set() + for t in raw: + for mult in (0.5, 1.0, 2.0, 2.0/3.0, 3.0/2.0, 4.0/3.0, 3.0/4.0): + v = t * mult + if 60 <= v <= 200: + candidates.add(round(v, 2)) + + if not candidates: + candidates = {round(t1, 2)} + + # --- Score each candidate --- + # Prefer tempos whose beat grid aligns well with detected onsets + best_score = -1 + best_bpm = t1 + onset_times_for_score = librosa.frames_to_time( + np.where(onset_env > np.percentile(onset_env, 75))[0], + sr=self.sr + ) + + for bpm_c in candidates: + beat_period = 60.0 / bpm_c + # For each onset, check distance to nearest beat-grid line + total_score = 0.0 + for ot in onset_times_for_score: + phase = (ot / beat_period) % 1.0 + # How close is this onset to a beat grid line? (0=perfect) + dist = min(phase, 1.0 - phase) + total_score += max(0.0, 0.5 - dist) # bonus if within half-beat + # Normalise + total_score /= max(len(onset_times_for_score), 1) + # Small bias toward the 80-130 range (most pop/reggaeton/hip-hop) + if 80 <= bpm_c <= 130: + total_score *= 1.10 + + if total_score > best_score: + best_score = total_score + best_bpm = bpm_c + + self._log(f" Best BPM candidate: {best_bpm:.1f} (score {best_score:.4f})", 38) + return best_bpm + + def detect_bpm_and_beats(self): + self._log("Detecting BPM & beats …", 32) + + if HAS_MADMOM: + self._log(" Using madmom (neural network) backend", 33) + self._detect_with_madmom() + else: + self._log(" Using librosa backend (install madmom for better accuracy)", 33) + self._detect_with_librosa() + + self._log(f"BPM ≈ {self.bpm:.1f} | {len(self.beat_times)} beats", 40) + self._log(f" First downbeat at {self.first_downbeat:.3f}s", 42) + + # ── madmom backend ──────────────────────────────────────────────── + + def _detect_with_madmom(self): + """Use madmom's neural-network models for BPM, beats, and downbeats. + + madmom's RNNBeatProcessor + DBNBeatTrackingProcessor is + state-of-the-art for beat tracking across genres. Its downbeat + model (RNNDownBeatProcessor) directly predicts which beat is + beat-1, eliminating the phase-guessing heuristic. + """ + # ---- BPM ---- + if self.bpm_override and self.bpm_override > 0: + self.bpm = self.bpm_override + self._log(f" Using manual BPM override: {self.bpm:.1f}", 34) + else: + self._log(" madmom: estimating tempo …", 34) + try: + act_proc = madmom.features.beats.RNNBeatProcessor() + act = act_proc(self.filepath) + tempo_proc = madmom.features.tempo.TempoEstimationProcessor(fps=100) + tempi = tempo_proc(act) # [[bpm, confidence], …] + if len(tempi) > 0: + self.bpm = float(tempi[0][0]) + self._log(f" madmom tempo: {self.bpm:.1f} BPM " + f"(confidence {tempi[0][1]:.2f})", 35) + else: + self._log(" madmom tempo failed, falling back to librosa", 35) + self.bpm = self._estimate_bpm_multimethod() + except Exception as e: + self._log(f" madmom tempo error: {e}, falling back to librosa", 35) + self.bpm = self._estimate_bpm_multimethod() + + self.bpm = round(self.bpm * 2) / 2 # snap to nearest 0.5 + + # ---- Beat tracking ---- + self._log(" madmom: tracking beats …", 36) + try: + act_proc = madmom.features.beats.RNNBeatProcessor() + act = act_proc(self.filepath) + beat_proc = madmom.features.beats.DBNBeatTrackingProcessor( + fps=100, min_bpm=max(40, self.bpm - 30), + max_bpm=min(240, self.bpm + 30) + ) + all_beat_times = beat_proc(act) + except Exception as e: + self._log(f" madmom beat tracking error: {e}, using librosa", 37) + _, beat_frames = librosa.beat.beat_track( + y=self.y, sr=self.sr, bpm=self.bpm + ) + all_beat_times = librosa.frames_to_time(beat_frames, sr=self.sr) + + self.beat_times = all_beat_times[all_beat_times >= self.music_start] + discarded = len(all_beat_times) - len(self.beat_times) + if discarded > 0: + self._log(f" Discarded {discarded} beats in leading silence", 37) + + # ---- Downbeat detection ---- + self._log(" madmom: detecting downbeats …", 38) + try: + db_proc = madmom.features.downbeats.RNNDownBeatProcessor() + db_act = db_proc(self.filepath) + dbn = madmom.features.downbeats.DBNDownBeatTrackingProcessor( + beats_per_bar=[4, 3], fps=100 + ) + downbeat_info = dbn(db_act) # Nx2 array: [[time, beat_num], …] + + # beat_num == 1 means downbeat (beat 1 of the bar) + downbeats = downbeat_info[downbeat_info[:, 1] == 1] + valid_db = downbeats[downbeats[:, 0] >= self.music_start - 0.1] + + if len(valid_db) > 0: + self.first_downbeat = float(valid_db[0, 0]) + self._log(f" madmom found {len(valid_db)} downbeats, " + f"first at {self.first_downbeat:.3f}s", 39) + else: + self._log(" No valid downbeats from madmom, using accent analysis", 39) + self._detect_downbeat_from_beat_strengths() + except Exception as e: + self._log(f" madmom downbeat error: {e}, using accent analysis", 39) + self._detect_downbeat_from_beat_strengths() + + # ── librosa backend ─────────────────────────────────────────────── + + def _detect_with_librosa(self): + """librosa-based BPM and beat detection with improved downbeat finding.""" + # ---- BPM ---- + if self.bpm_override and self.bpm_override > 0: + self.bpm = self.bpm_override + self._log(f" Using manual BPM override: {self.bpm:.1f}", 34) + else: + self.bpm = self._estimate_bpm_multimethod() + + self.bpm = round(self.bpm * 2) / 2 # snap to nearest 0.5 + + # ---- Beat tracking with the chosen BPM as hint ---- + _, beat_frames = librosa.beat.beat_track( + y=self.y, sr=self.sr, bpm=self.bpm + ) + all_beat_times = librosa.frames_to_time(beat_frames, sr=self.sr) + + # ---- Filter out beats before music actually starts ---- + self.beat_times = all_beat_times[all_beat_times >= self.music_start] + discarded = len(all_beat_times) - len(self.beat_times) + if discarded > 0: + self._log(f" Discarded {discarded} beats in leading silence", 39) + + # ---- Find the first downbeat via accent-pattern analysis ---- + self._detect_downbeat_from_beat_strengths() + + # ── downbeat detection by accent analysis ───────────────────────── + + def _detect_downbeat_from_beat_strengths(self): + """Find the first downbeat by analysing accent patterns. + + In 4/4 music beat 1 (the downbeat) is typically the loudest / + most accented beat in the bar. We test all 4 possible phase + alignments (does beat 1 land on the 0th, 1st, 2nd, or 3rd + detected beat?) and pick the phase whose "downbeats" have the + highest average onset-strength × RMS-energy product. + """ + if len(self.beat_times) < 8: + self.first_downbeat = ( + float(self.beat_times[0]) if len(self.beat_times) else 0.0 + ) + return + + # Onset strength at every beat position + onset_env = librosa.onset.onset_strength(y=self.y, sr=self.sr) + beat_frames = librosa.time_to_frames(self.beat_times, sr=self.sr) + beat_frames = np.clip(beat_frames, 0, len(onset_env) - 1) + beat_strengths = onset_env[beat_frames] + + # Also get low-frequency (bass) energy at each beat — bass hits + # strongly correlate with downbeats in most genres. + S = np.abs(librosa.stft(self.y, n_fft=2048, hop_length=512)) + bass_band = S[:8, :] # lowest ~170 Hz + bass_energy = np.mean(bass_band, axis=0) + bass_frames = librosa.time_to_frames( + self.beat_times, sr=self.sr, hop_length=512 + ) + bass_frames = np.clip(bass_frames, 0, bass_energy.shape[0] - 1) + bass_at_beats = bass_energy[bass_frames] + # normalise + bass_max = np.max(bass_at_beats) if np.max(bass_at_beats) > 0 else 1.0 + bass_at_beats = bass_at_beats / bass_max + + best_phase = 0 + best_score = -1.0 + + for phase in range(4): + # Indices of beats that would be "beat 1" under this phase + db_idx = np.arange(phase, len(beat_strengths), 4) + other_idx = np.array( + [i for i in range(len(beat_strengths)) if (i - phase) % 4 != 0] + ) + + if len(db_idx) == 0: + continue + + db_str = beat_strengths[db_idx] + other_str = ( + beat_strengths[other_idx] if len(other_idx) else np.array([1.0]) + ) + + # Accent ratio: downbeats should be louder + strength_ratio = np.mean(db_str) / (np.mean(other_str) + 1e-8) + + # RMS energy at candidate downbeat positions + db_times = self.beat_times[db_idx] + rms_values = np.array([self.get_rms_at(t) for t in db_times]) + rms_score = np.mean(rms_values) + + # Bass energy boost — bass drum typically hits on beat 1 + bass_score = np.mean(bass_at_beats[db_idx]) + + # Combined score + score = strength_ratio * (1.0 + rms_score) * (1.0 + bass_score) + + # Slight preference for phase 0 (first detected beat is often + # beat 1, so break ties in its favour) + if phase == 0: + score *= 1.05 + + self._log(f" Downbeat phase {phase}: score={score:.3f} " + f"(accent={strength_ratio:.2f}, rms={rms_score:.2f}, " + f"bass={bass_score:.2f})", 39) + + if score > best_score: + best_score = score + best_phase = phase + + self.first_downbeat = float(self.beat_times[best_phase]) + + # Walk backwards to the earliest valid downbeat + beat_period = 60.0 / self.bpm + measure_duration = 4 * beat_period + while (self.first_downbeat - measure_duration + >= self.music_start - beat_period * 0.25): + self.first_downbeat -= measure_duration + + self._log(f" Best downbeat: phase={best_phase}, " + f"score={best_score:.3f}", 40) + + def detect_onsets(self): + self._log("Detecting onsets …", 50) + env = librosa.onset.onset_strength(y=self.y, sr=self.sr) + frames = librosa.onset.onset_detect( + y=self.y, sr=self.sr, onset_envelope=env, backtrack=False + ) + self.onset_times = librosa.frames_to_time(frames, sr=self.sr) + raw = env[frames] if len(frames) else np.array([]) + mx = raw.max() if len(raw) else 1.0 + self.onset_strengths = raw / mx if mx > 0 else raw + self._log(f"Found {len(self.onset_times)} onsets", 65) + + def get_dominant_band(self, t: float) -> int: + """Return dominant frequency band 0-3 at time *t*. + + Band mapping (used for arrow assignment): + 0 → bass → Left + 1 → low-mid → Down + 2 → mid-high → Up + 3 → high → Right + """ + frame = int(librosa.time_to_frames([t], sr=self.sr)[0]) + frame = np.clip(frame, 0, self.mel_spec.shape[1] - 1) + spec = self.mel_spec[:, frame] + bs = self.n_mels // 4 + energies = [ + np.mean(spec[i * bs: (i + 1) * bs if i < 3 else self.n_mels]) + for i in range(4) + ] + return int(np.argmax(energies)) + + def get_rms_at(self, t: float) -> float: + """Return the normalised RMS energy (0..1) at time *t*.""" + if self.rms is None: + return 1.0 + frame = int(librosa.time_to_frames([t], sr=self.sr, hop_length=512)[0]) + frame = np.clip(frame, 0, len(self.rms) - 1) + peak = np.max(self.rms) + return float(self.rms[frame] / peak) if peak > 0 else 0.0 + + # -- public API -- + def analyze(self): + self.load_audio() + self.compute_mel_spectrogram() + self.detect_music_start() + self.detect_bpm_and_beats() + self.detect_onsets() + self._log("Audio analysis complete!", 70) + return self + + +# ────────────────────────────────────────────────────────────────────────────── +# Step-Chart Generation +# ────────────────────────────────────────────────────────────────────────────── + +class StepChartGenerator: + """Turns audio-analysis results into playable DDR step charts.""" + + LEFT_FOOT = [0, 1] # Left, Down + RIGHT_FOOT = [2, 3] # Up, Right + + CONFIGS = { + 'Beginner': dict( + level=1, subdiv=4, use_beats_only=True, beat_skip=2, + onset_thresh=0.95, jump_prob=0.00, max_nps=1.5, + jack_ok=False, alt_pref=True, + ), + 'Easy': dict( + level=3, subdiv=4, use_beats_only=True, beat_skip=1, + onset_thresh=0.75, jump_prob=0.03, max_nps=3.0, + jack_ok=False, alt_pref=True, + ), + 'Medium': dict( + level=6, subdiv=8, use_beats_only=False, beat_skip=1, + onset_thresh=0.40, jump_prob=0.08, max_nps=5.0, + jack_ok=False, alt_pref=False, + ), + 'Hard': dict( + level=8, subdiv=16, use_beats_only=False, beat_skip=1, + onset_thresh=0.25, jump_prob=0.12, max_nps=9.0, + jack_ok=True, alt_pref=False, + ), + 'Challenge': dict( + level=10, subdiv=16, use_beats_only=False, beat_skip=1, + onset_thresh=0.10, jump_prob=0.18, max_nps=14.0, + jack_ok=True, alt_pref=False, + ), + } + + def __init__(self, analyzer: AudioAnalyzer, seed=None, callback=None): + self.az = analyzer + self._cb = callback or (lambda m, p: None) + self.rng = random.Random(seed) + self.charts: dict = {} + + def _log(self, msg, pct=0): + self._cb(msg, pct) + + # -- arrow assignment -- + def _pick_arrow(self, t, prev, cfg): + band = self.az.get_dominant_band(t) + arrow = band + + # 30 % random variety + if self.rng.random() < 0.30: + arrow = self.rng.randint(0, 3) + + # easy diffs: alternate left-side / right-side + if cfg['alt_pref'] and prev: + last = prev[-1] + arrow = self.rng.choice( + self.RIGHT_FOOT if last in self.LEFT_FOOT else self.LEFT_FOOT + ) + + # avoid jacks on lower diffs + if not cfg['jack_ok'] and prev: + for _ in range(12): + if arrow != prev[-1]: + break + arrow = self.rng.randint(0, 3) + + row = [0, 0, 0, 0] + row[arrow] = 1 + + # jumps + if cfg['jump_prob'] and self.rng.random() < cfg['jump_prob']: + alt = [i for i in range(4) if i != arrow] + row[self.rng.choice(alt)] = 1 + + return row, arrow + + # -- post-processing rules (ergonomic / musical polish) -- + + def _postprocess(self, measures, subdiv, cfg): + """Apply rules to make charts feel more natural & playable.""" + bpm = self.az.bpm + spm = 4 * 60.0 / bpm + spr = spm / subdiv + offset = self.az.first_downbeat + + # ---------- Rule 1: Mute arrows during quiet sections ---------- + for m_idx, meas in enumerate(measures): + for r_idx, row in enumerate(meas): + if not any(v > 0 for v in row): + continue + t = offset + (m_idx * subdiv + r_idx) * spr + energy = self.az.get_rms_at(t) + if energy < 0.08: # very quiet + row[:] = [0, 0, 0, 0] + + # ---------- Rule 2: Avoid crossovers on lower diffs ---------- + # L,D = left foot; U,R = right foot + # Bad crossover: last was L(0) and now R(3) immediately → ugly + if cfg['level'] <= 6: + prev_arrow = -1 + for meas in measures: + for row in meas: + arrows = [i for i in range(4) if row[i]] + if len(arrows) == 1: + a = arrows[0] + # Crossover: left-foot arrow → right-foot arrow skipping middle + if prev_arrow == 0 and a == 3: # L → R + row[:] = [0, 0, 1, 0] # switch to Up + elif prev_arrow == 3 and a == 0: # R → L + row[:] = [0, 1, 0, 0] # switch to Down + prev_arrow = [i for i in range(4) if row[i]][0] if any(row) else prev_arrow + + # ---------- Rule 3: Add emphasis jumps on downbeats (med+ diffs) ---------- + if cfg['level'] >= 5: + for m_idx, meas in enumerate(measures): + # Downbeat = first row of each measure + row = meas[0] + if any(v > 0 for v in row) and sum(row) == 1: + t = offset + (m_idx * subdiv) * spr + energy = self.az.get_rms_at(t) + # Strong downbeat → maybe add a jump + if energy > 0.70 and self.rng.random() < 0.20: + active = row.index(1) + # Add opposite-side arrow for jump + if active in self.LEFT_FOOT: + partner = self.rng.choice(self.RIGHT_FOOT) + else: + partner = self.rng.choice(self.LEFT_FOOT) + row[partner] = 1 + + # ---------- Rule 4: Smooth runs (hard+ diffs) ---------- + # When 4+ consecutive notes exist, make them flow L→D→U→R or reverse + if cfg['level'] >= 8: + flat = [(m_idx, r_idx, meas[r_idx]) + for m_idx, meas in enumerate(measures) + for r_idx in range(len(meas))] + run_start = None + run_len = 0 + for i, (mi, ri, row) in enumerate(flat): + has_note = any(v > 0 for v in row) and sum(row) == 1 + if has_note: + if run_start is None: + run_start = i + run_len += 1 + else: + if run_len >= 4: + self._smooth_run(flat, run_start, run_len) + run_start = None + run_len = 0 + if run_len >= 4: + self._smooth_run(flat, run_start, run_len) + + # ---------- Rule 5: Gap between jumps ---------- + # Ensure at least 2 rows between consecutive jumps + last_jump_gi = -999 + for m_idx, meas in enumerate(measures): + for r_idx, row in enumerate(meas): + gi = m_idx * subdiv + r_idx + if sum(row) >= 2: # jump + if gi - last_jump_gi < 3 and cfg['level'] < 9: + # Too close → downgrade to single + arrows = [i for i in range(4) if row[i]] + keep = self.rng.choice(arrows) + row[:] = [0, 0, 0, 0] + row[keep] = 1 + else: + last_jump_gi = gi + + return measures + + def _smooth_run(self, flat, start, length): + """Turn a consecutive run into a flowing L→D→U→R pattern.""" + # Pick direction + patterns = [ + [0, 1, 2, 3], # L D U R + [3, 2, 1, 0], # R U D L + [0, 2, 1, 3], # L U D R (staircase) + [3, 1, 2, 0], # R D U L + ] + pat = self.rng.choice(patterns) + for i in range(length): + _, _, row = flat[start + i] + arrow = pat[i % 4] + row[:] = [0, 0, 0, 0] + row[arrow] = 1 + + # -- chart for one difficulty -- + def generate_chart(self, name): + cfg = self.CONFIGS[name] + bpm = self.az.bpm + # Use the first downbeat as reference, not just beat_times[0] + offset = self.az.first_downbeat + bpmeas = 4 # beats per measure (4/4) + spm = bpmeas * 60.0 / bpm # seconds per measure + subdiv = cfg['subdiv'] + spr = spm / subdiv # seconds per row + n_meas = int(np.ceil((self.az.duration - offset) / spm)) + 1 + + # ---- collect grid positions that should have notes ---- + note_grid = set() + + # beats + beats = self.az.beat_times[::cfg['beat_skip']] + for bt in beats: + if bt >= self.az.music_start and bt <= self.az.duration: + ri = round((bt - offset) / spr) + if ri >= 0 and abs((bt - offset) / spr - ri) < 0.45: + note_grid.add(ri) + + # onsets + for ot, os_ in zip(self.az.onset_times, self.az.onset_strengths): + if os_ >= cfg['onset_thresh'] and ot >= self.az.music_start and ot <= self.az.duration: + ri = round((ot - offset) / spr) + if ri >= 0 and abs((ot - offset) / spr - ri) < 0.45: + note_grid.add(ri) + + # density cap + max_notes = int(self.az.duration * cfg['max_nps']) + if len(note_grid) > max_notes: + lst = sorted(note_grid) + step = len(lst) / max_notes + note_grid = {lst[int(i * step)] for i in range(max_notes)} + + # ---- build measures ---- + measures, prev = [], [] + for m in range(n_meas): + mrows = [] + for r in range(subdiv): + gi = m * subdiv + r + trow = offset + gi * spr + if gi in note_grid and 0 <= trow <= self.az.duration: + row, arrow = self._pick_arrow(trow, prev, cfg) + mrows.append(row) + prev.append(arrow) + prev = prev[-8:] + else: + mrows.append([0, 0, 0, 0]) + measures.append(mrows) + + # ---- post-processing ---- + measures = self._postprocess(measures, subdiv, cfg) + + # trim trailing empty measures (keep at least 1) + while len(measures) > 1 and all( + all(v == 0 for v in r) for r in measures[-1] + ): + measures.pop() + + note_count = sum( + 1 for ms in measures for r in ms if any(v > 0 for v in r) + ) + return dict( + difficulty=name, level=cfg['level'], subdiv=subdiv, + measures=measures, note_count=note_count, + ) + + # -- generate all selected difficulties -- + def generate_all(self, selected=None): + selected = selected or list(self.CONFIGS) + base = 72 + for i, name in enumerate(selected): + self._log(f"Generating {name} …", base + i * 5) + self.charts[name] = self.generate_chart(name) + self._log( + f" {name}: {self.charts[name]['note_count']} notes", + base + (i + 1) * 5, + ) + self._log("All charts generated!", 95) + return self.charts + + +# ────────────────────────────────────────────────────────────────────────────── +# .sm File Writer +# ────────────────────────────────────────────────────────────────────────────── + +class SMFileWriter: + """Serialises step charts to the StepMania .sm format.""" + + def __init__( + self, + analyzer: AudioAnalyzer, + charts: dict, + path: str, + music_file: str = None, + video_file: str = None + ): + self.az = analyzer + self.charts = charts + self.path = path + self.music_file = music_file or analyzer.filepath + self.video_file = video_file + + def write(self): + title = Path(self.az.filepath).stem + music = os.path.basename(self.music_file) + video = os.path.basename(self.video_file) if self.video_file else None + # Use the first downbeat (properly aligned) for the SM offset + offset = -self.az.first_downbeat + preview = self.az.duration * 0.30 + + if video: + bgchanges = f"#BGCHANGES:0.000000={video}=1.000000=0=0=0=0;\n" + else: + bgchanges = "#BGCHANGES:;\n" + + hdr = ( + f"#TITLE:{title};\n" + f"#SUBTITLE:;\n" + f"#ARTIST:Unknown Artist;\n" + f"#TITLETRANSLIT:;\n" + f"#SUBTITLETRANSLIT:;\n" + f"#ARTISTTRANSLIT:;\n" + f"#GENRE:;\n" + f"#CREDIT:Auto-generated by SM Generator;\n" + f"#BANNER:;\n" + f"#BACKGROUND:;\n" + f"#LYRICSPATH:;\n" + f"#CDTITLE:;\n" + f"#MUSIC:{music};\n" + f"#OFFSET:{offset:.6f};\n" + f"#SAMPLESTART:{preview:.6f};\n" + f"#SAMPLELENGTH:15.000000;\n" + f"#SELECTABLE:YES;\n" + f"#BPMS:0.000000={self.az.bpm:.6f};\n" + f"#STOPS:;\n" + f"{bgchanges}" + ) + + parts = [hdr] + for name in ('Beginner', 'Easy', 'Medium', 'Hard', 'Challenge'): + if name not in self.charts: + continue + ch = self.charts[name] + notes_hdr = ( + f"\n//---------------dance-single - {name}---------------\n" + f"#NOTES:\n" + f" dance-single:\n" + f" :\n" + f" {name}:\n" + f" {ch['level']}:\n" + f" 0.000000,0.000000,0.000000,0.000000,0.000000:\n" + ) + measure_strs = [] + for meas in ch['measures']: + rows = '\n'.join(''.join(str(v) for v in r) for r in meas) + measure_strs.append(rows) + notes_body = '\n,\n'.join(measure_strs) + '\n;\n' + parts.append(notes_hdr + notes_body) + + with open(self.path, 'w', encoding='utf-8') as f: + f.writelines(parts) + return self.path + + +# ────────────────────────────────────────────────────────────────────────────── +# Tkinter GUI +# ────────────────────────────────────────────────────────────────────────────── + +class App: + """Main application window.""" + + AUDIO_TYPES = ( + ('Audio files', '*.mp3 *.ogg *.opus *.wav *.flac *.m4a *.wma *.aac *.webm'), + ('All files', '*.*'), + ) + + VIDEO_TYPES = ( + ('Video files', '*.mp4 *.mkv *.avi *.mov *.webm *.m4v *.wmv *.flv'), + ('All files', '*.*'), + ) + + def __init__(self): + self.root = tk.Tk() + self.root.title("StepMania .sm Generator") + self.root.geometry("740x620") + self.root.minsize(620, 520) + + # variables + self.v_in = tk.StringVar() + self.v_out = tk.StringVar() + self.v_vid = tk.StringVar() + self.v_stat = tk.StringVar(value="Ready — select an audio file to begin.") + self.v_prog = tk.DoubleVar() + self.v_seed = tk.StringVar() + self.v_bpm = tk.StringVar() # manual BPM override + self.diff_vars: dict[str, tk.BooleanVar] = {} + + self._build() + + # ---- UI construction ---- + def _build(self): + m = ttk.Frame(self.root, padding=14) + m.pack(fill=tk.BOTH, expand=True) + + ttk.Label(m, text="StepMania .sm Generator", + font=('Segoe UI', 18, 'bold')).pack(pady=(0, 10)) + + # input + fi = ttk.LabelFrame(m, text="Audio File (MP3 · OGG · OPUS · WAV · FLAC …)", padding=8) + fi.pack(fill=tk.X, pady=4) + ttk.Entry(fi, textvariable=self.v_in).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0,8)) + ttk.Button(fi, text="Browse …", command=self._browse_in).pack(side=tk.RIGHT) + + # video (optional) + fv = ttk.LabelFrame(m, text="Background Video (optional)", padding=8) + fv.pack(fill=tk.X, pady=4) + ttk.Entry(fv, textvariable=self.v_vid).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0,8)) + ttk.Button(fv, text="Browse …", command=self._browse_vid).pack(side=tk.RIGHT) + + # output + fo = ttk.LabelFrame(m, text="Output .sm File", padding=8) + fo.pack(fill=tk.X, pady=4) + ttk.Entry(fo, textvariable=self.v_out).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0,8)) + ttk.Button(fo, text="Browse …", command=self._browse_out).pack(side=tk.RIGHT) + + # difficulties + fd = ttk.LabelFrame(m, text="Difficulties", padding=8) + fd.pack(fill=tk.X, pady=4) + for n in ('Beginner', 'Easy', 'Medium', 'Hard', 'Challenge'): + v = tk.BooleanVar(value=True) + self.diff_vars[n] = v + ttk.Checkbutton(fd, text=n, variable=v).pack(side=tk.LEFT, padx=8) + + # options + fopt = ttk.LabelFrame(m, text="Options", padding=8) + fopt.pack(fill=tk.X, pady=4) + ttk.Label(fopt, text="BPM:").pack(side=tk.LEFT, padx=(0,4)) + ttk.Entry(fopt, textvariable=self.v_bpm, width=8).pack(side=tk.LEFT) + ttk.Label(fopt, text="(auto-detect if empty)").pack(side=tk.LEFT, padx=(2,14)) + ttk.Label(fopt, text="Seed:").pack(side=tk.LEFT, padx=(0,4)) + ttk.Entry(fopt, textvariable=self.v_seed, width=10).pack(side=tk.LEFT) + ttk.Label(fopt, text="(random if empty)").pack(side=tk.LEFT, padx=4) + + # generate + self.btn = ttk.Button(m, text=" Generate .sm File ", + command=self._on_gen) + self.btn.pack(pady=14) + + # progress + self.pb = ttk.Progressbar(m, variable=self.v_prog, maximum=100) + self.pb.pack(fill=tk.X, pady=4) + + # log + fl = ttk.LabelFrame(m, text="Log", padding=4) + fl.pack(fill=tk.BOTH, expand=True, pady=4) + self.log_w = tk.Text(fl, height=10, state=tk.DISABLED, + wrap=tk.WORD, font=('Consolas', 9)) + sb = ttk.Scrollbar(fl, orient=tk.VERTICAL, command=self.log_w.yview) + self.log_w.configure(yscrollcommand=sb.set) + sb.pack(side=tk.RIGHT, fill=tk.Y) + self.log_w.pack(fill=tk.BOTH, expand=True) + + # status + ttk.Label(m, textvariable=self.v_stat, + relief=tk.SUNKEN, anchor=tk.W).pack(fill=tk.X, pady=(4,0)) + + # ---- callbacks ---- + def _browse_in(self): + p = filedialog.askopenfilename(title="Select Audio", filetypes=self.AUDIO_TYPES) + if p: + self.v_in.set(p) + self.v_out.set(str(Path(p).with_suffix('.sm'))) + + def _browse_out(self): + p = filedialog.asksaveasfilename( + title="Save .sm", defaultextension='.sm', + filetypes=[('StepMania', '*.sm'), ('All', '*.*')]) + if p: + self.v_out.set(p) + + def _browse_vid(self): + p = filedialog.askopenfilename(title="Select Video", filetypes=self.VIDEO_TYPES) + if p: + self.v_vid.set(p) + + def _log(self, msg, pct=None): + def _do(): + self.log_w.config(state=tk.NORMAL) + self.log_w.insert(tk.END, msg + '\n') + self.log_w.see(tk.END) + self.log_w.config(state=tk.DISABLED) + self.v_stat.set(msg) + if pct is not None: + self.v_prog.set(pct) + self.root.after(0, _do) + + def _on_gen(self): + inp = self.v_in.get().strip() + out = self.v_out.get().strip() + vid = self.v_vid.get().strip() + if not inp: + return messagebox.showwarning("Warning", "Select an audio file first.") + if not os.path.isfile(inp): + return messagebox.showerror("Error", f"File not found:\n{inp}") + if vid and not os.path.isfile(vid): + return messagebox.showerror("Error", f"Video file not found:\n{vid}") + if not out: + return messagebox.showwarning("Warning", "Specify an output path.") + diffs = [n for n, v in self.diff_vars.items() if v.get()] + if not diffs: + return messagebox.showwarning("Warning", "Pick at least one difficulty.") + + s = self.v_seed.get().strip() + seed = int(s) if s.isdigit() else None + + bpm_str = self.v_bpm.get().strip() + bpm_override = None + if bpm_str: + try: + bpm_override = float(bpm_str) + if bpm_override <= 0 or bpm_override > 300: + return messagebox.showwarning("Warning", "BPM must be between 1 and 300.") + except ValueError: + return messagebox.showwarning("Warning", f"Invalid BPM value: '{bpm_str}'") + + self.btn.config(state=tk.DISABLED) + self.v_prog.set(0) + threading.Thread( + target=self._pipeline, args=(inp, out, vid, diffs, seed, bpm_override), daemon=True + ).start() + + def _pipeline(self, inp, out, vid, diffs, seed, bpm_override=None): + try: + # Convert to MP3 if needed + mp3_path = convert_to_mp3(inp, callback=self._log) + + video_path = None + if vid: + video_path = convert_to_mp4_video(vid, callback=self._log) + + az = AudioAnalyzer(inp, callback=self._log, bpm_override=bpm_override) + az.analyze() + + gen = StepChartGenerator(az, seed=seed, callback=self._log) + charts = gen.generate_all(selected=diffs) + + self._log("Writing .sm file …", 97) + SMFileWriter(az, charts, out, music_file=mp3_path, video_file=video_path).write() + + self._log(f"Done! → {out}", 100) + self._log(f" BPM: {az.bpm:.1f} | Duration: {az.duration:.1f}s") + for d in diffs: + c = charts[d] + self._log(f" {d}: level {c['level']}, {c['note_count']} notes") + + self.root.after(0, lambda: messagebox.showinfo( + "Success", + f"StepMania file generated!\n\n" + f"BPM: {az.bpm:.1f}\n" + f"Duration: {az.duration:.1f}s\n" + f"Difficulties: {', '.join(diffs)}\n\n" + f"Saved to:\n{out}" + )) + except Exception as e: + self._log(f"ERROR: {e}", 0) + self._log(traceback.format_exc()) + self.root.after(0, lambda: messagebox.showerror("Error", str(e))) + finally: + self.root.after(0, lambda: self.btn.config(state=tk.NORMAL)) + + def run(self): + self.root.mainloop() + + +# ────────────────────────────────────────────────────────────────────────────── +if __name__ == '__main__': + App().run() diff --git a/truenas/ExtractMounts-freenasv1.py b/truenas/ExtractMounts-freenasv1.py new file mode 100644 index 0000000..fd56574 --- /dev/null +++ b/truenas/ExtractMounts-freenasv1.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +import sqlite3 +import os +""" +Tool to extract and print SMB/CIFS and NFS share configurations from a FreeNAS v1 +SQLite database. It reads share records, resolves NFS paths from auxiliary tables +when available, and outputs a human-readable summary with key settings and +additional properties, while omitting internal IDs. Intended for quick auditing +or migration reporting of share configurations. +""" + +DB_FILE = 'freenas-v1.db' + +def dict_factory(cursor, row): + d = {} + for idx, col in enumerate(cursor.description): + d[col[0]] = row[idx] + return d + +def print_section(title): + print("\n" + "#" * 60) + print(f" {title}") + print("#" * 60) + +def dump_row(row, indent=2): + """Print all columns that have data (ignore None or empty)""" + max_len = 0 + # Calculate width for alignment + valid_keys = [k for k, v in row.items() if v is not None and v != ''] + if not valid_keys: return + + max_len = max(len(k) for k in valid_keys) + + for key in valid_keys: + # Omit internal IDs that do not provide useful info to the user + if key == 'id': continue + val = row[key] + print(f"{' ' * indent}{key.ljust(max_len)} : {val}") + +def get_smb_shares(cursor): + print_section("SMB / CIFS SHARES (Complete Configuration)") + try: + cursor.execute("SELECT * FROM sharing_cifs_share") + shares = cursor.fetchall() + + if not shares: + print("No SMB shares found.") + return + + for share in shares: + print(f"\n--- Share: {share.get('cifs_name', 'No Name')} ---") + + # Print main path + print(f" Main Path : {share.get('cifs_path', 'N/A')}") + + # Print Hosts Allow/Deny specifically if they exist + if share.get('cifs_hostsallow'): + print(f" HOSTS ALLOW : {share['cifs_hostsallow']}") + if share.get('cifs_hostsdeny'): + print(f" HOSTS DENY : {share['cifs_hostsdeny']}") + + # Print the rest of the properties dynamically + print(" [Additional Details]:") + dump_row(share, indent=4) + + except sqlite3.OperationalError as e: + print(f"Error reading SMB table: {e}") + +def get_nfs_shares(cursor): + print_section("NFS SHARES (Complete Configuration)") + try: + cursor.execute("SELECT * FROM sharing_nfs_share") + nfs_shares = cursor.fetchall() + + if not nfs_shares: + print("No NFS shares found.") + return + + # Try to detect the name of the paths table + path_table = None + try: + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='sharing_nfs_share_paths'") + if cursor.fetchone(): path_table = 'sharing_nfs_share_paths' + else: + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='sharing_nfs_share_path'") + if cursor.fetchone(): path_table = 'sharing_nfs_share_path' + except: + pass + + for share in nfs_shares: + share_id = share.get('id') + print(f"\n--- NFS Share ID: {share_id} ---") + + # 1. Intentar obtener rutas (Paths) + paths = [] + if path_table: + try: + cursor.execute(f"SELECT path FROM {path_table} WHERE share_id = ?", (share_id,)) + rows = cursor.fetchall() + paths = [r['path'] for r in rows] + except Exception as e: + paths = [f"Error reading paths: {e}"] + + # Fallback for older versions where the path was in the main table + if not paths and 'nfs_path' in share and share['nfs_path']: + paths = [share['nfs_path']] + + if paths: + print(f" PATHS : {', '.join(paths)}") + else: + print(f" PATHS : [NOT FOUND OR EMPTY]") + + # 2. Hosts / Networks + if share.get('nfs_network'): + print(f" ALLOWED NETWORKS: {share['nfs_network']}") + if share.get('nfs_hosts'): + print(f" ALLOWED HOSTS : {share['nfs_hosts']}") + + # 3. Other details + print(" [Configuration]:") + dump_row(share, indent=4) + + except sqlite3.OperationalError as e: + print(f"Error reading NFS table: {e}") + +def main(): + if not os.path.exists(DB_FILE): + print(f"ERROR: '{DB_FILE}' not found") + return + + conn = sqlite3.connect(DB_FILE) + conn.row_factory = dict_factory + cursor = conn.cursor() + + get_smb_shares(cursor) + get_nfs_shares(cursor) + conn.close() + +if __name__ == "__main__": + main()