Better difficulties, no bias for bass

This commit is contained in:
2026-02-11 18:51:59 +01:00
parent a057953f4c
commit 01cfb2be41
3 changed files with 350 additions and 25 deletions

157
stepmania-ddr/README.md Normal file
View File

@@ -0,0 +1,157 @@
# Herramientas de StepMania DDR
Este repositorio contiene dos herramientas útiles para trabajar con fichreos de **StepMania DDR (Dance Dance Revolution)**:
## 📋 Tabla de Contenidos
1. [StepMania Simplifier](#stepmania-simplifier)
2. [StepMania SM Generator](#stepmania-sm-generator)
3. [Requisitos Generales](#requisitos-generales)
---
## 🔨 StepMania Simplifier
### ¿Qué hace?
**stepmania_simplifier.py** es una herramienta con interfaz gráfica (GUI) que permite **simplificar ficheros sm de StepMania existentes**. Es perfecta para:
- Crear versiones más fáciles de un gráfico base
- Reducir la dificultad eliminando patrones complejos
- Generar nuevas dificultades automáticamente a partir de una existente
### Características
La herramienta permite:
- **Eliminar notas rápidas**: Manteniendo o no además un porcentaje de ellas.
- **Eliminar saltos**: Opción para eliminar notas simultáneas (jumps)
- **Simplificar patrones**: Convertiendo holds largos en notas normales
- **Crear nueva dificultad**: La dificultad simplificada se guarda como una nueva dificultad con un nombre personalizado
### Dependencias
- Tkinter, incluida en Python en casi todos los casos.
### Cómo usar
1. Abre la aplicación:
2. Selecciona un archivo `.sm` (click en "Examinar")
3. El programa analizará automáticamente el archivo y mostrará las dificultades disponibles
4. Elige la dificultad base desde el dropdown "Chart Base"
5. Configura las opciones de simplificación según tus necesidades
6. Define el nombre para la nueva dificultad
7. Click en "Generar Versión Simplificada"
8. El archivo se guardará con la nueva dificultad añadida
---
## 🎵 StepMania SM Generator
### ¿Qué hace?
**stepmania_sm_generator.py** es un **generador automático de ficheros de StepMania a partir de archivos de audio**. Analiza la música y genera automáticamente los steps (pasos) con dificultades variables.
### Características
- Detección automática de **BPM** (tempo)
- Detección de **beats** y downbeats
- Detección de **onsets** (cambios musicales)
- Análisis espectral para posicionamiento inteligente de flechas
- Generación de múltiples niveles de dificultad (Beginner → Challenge)
- Conversión automática de audio a MP3 y videos a MP4 sin audio
### Dependencias
#### Requeridas:
```bash
pip install librosa numpy soundfile
```
**⚠️ Dependencia Opcional madmom, recomendada**:
La detección de BPM es MUCHO más precisa con `madmom`. Sin madmom, el programa usa `librosa` que puede equivocarse con géneros sincopados (reggaeton, trap, etc.)
Para Python > 3.9, instala desde el repositorio de GitHub:
```bash
pip install git+https://github.com/CPJKU/madmom.git
```
Para Python 3.8 o anterior:
```bash
pip install madmom
```
#### Sistema:
- **ffmpeg**: Necesario para decodificar audio y video
- Ubuntu/Debian: `sudo apt install ffmpeg`
- macOS: `brew install ffmpeg`
- Windows: Descarga desde https://ffmpeg.org/download.html
### Cómo usar
1. Selecciona un archivo de audio (MP3, WAV, OGG, FLAC, etc.)
2. Selecciona un archivo de vídeo (Opcional)
3. El programa:
- Analiza automáticamente el audio
- Detecta el BPM y beats
- Genera los steps para múltiples dificultades
- Convierte archivos de video si es necesario
- Genera el archivo `.sm` final
### 🎯 Notas Importantes sobre BPM
El **BPM correcto es fundamental** para la calidad del gráfico generado. Si el programa no lo reconoce correctamente:
#### ¿Por qué es importante el BPM?
- Un BPM incorrecto hace que los beats no sincronicen con la música
- Los steps quedarán desalineados
- El gráfico será injugable
#### ¿Qué hacer si el BPM es incorrecto?
1. **Verificar el BPM manualmente** usando herramientas web:
- [BPM Detector Online](https://www.online-convert.com/file-converter)
- [Spotify](https://open.spotify.com) - Ver detalles de la canción
- [Tunebat](https://tunebat.com) - Detecta BPM de canciones
2. **Editar el BPM manualmente**: Añádelo en la GUI en el apartado de forzar BPM
---
## 🐛 Solución de Problemas
### "ffmpeg not found"
- Linux: `sudo apt install ffmpeg`
- macOS: `brew install ffmpeg`
- Windows: Descarga e instala desde https://ffmpeg.org/
### BPM detectado incorrectamente
- Asegúrate de tener **madmom** instalado
- Verifica manualmente el BPM usando herramientas web
- Edita el BPM en la GUI antes de hacer el proceso.
---
## 📝 Licencia
Estas herramientas fueron creadas para trabajar con ficheros de StepMania DDR (.sm).

View File

@@ -1,4 +1,5 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
import os

View File

@@ -1,22 +1,5 @@
#!/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)
"""
# -*- coding: utf-8 -*-
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
@@ -703,10 +686,42 @@ class StepChartGenerator:
def _log(self, msg, pct=0):
self._cb(msg, pct)
def _row_color(self, subdiv, r_idx):
"""Return the pulse color for a row index within a measure."""
if subdiv == 8:
return 'red' if r_idx % 2 == 0 else 'blue'
if subdiv == 16:
mod = r_idx % 4
if mod == 0:
return 'red'
if mod == 2:
return 'blue'
return 'yellow'
return 'red'
def _map_band_to_arrow(self, t, band, cfg):
"""Map a spectral band to an arrow index with bass rotation."""
if band != 0:
return band
bpm = max(self.az.bpm, 1.0)
measure_len = 4.0 * 60.0 / bpm
measure_idx = int(max(t, 0.0) / measure_len)
# Rotate bass across lanes to avoid left bias; slower on lower diffs.
level = cfg.get('level', 6)
if level <= 3:
measures_per_step = 4
elif level <= 6:
measures_per_step = 2
else:
measures_per_step = 1
step_idx = measure_idx // measures_per_step
bass_cycle = [0, 1, 2, 3]
return bass_cycle[step_idx % 4]
# -- arrow assignment --
def _pick_arrow(self, t, prev, cfg):
def _pick_arrow(self, t, prev, cfg, color=None):
band = self.az.get_dominant_band(t)
arrow = band
arrow = self._map_band_to_arrow(t, band, cfg)
# 30 % random variety
if self.rng.random() < 0.30:
@@ -730,7 +745,14 @@ class StepChartGenerator:
row[arrow] = 1
# jumps
if cfg['jump_prob'] and self.rng.random() < cfg['jump_prob']:
jump_prob = cfg['jump_prob']
if cfg['level'] == 6 and color:
if color == 'red':
jump_prob = min(0.35, jump_prob * 1.6)
elif color == 'blue':
jump_prob = jump_prob * 0.4
if jump_prob and self.rng.random() < jump_prob:
alt = [i for i in range(4) if i != arrow]
row[self.rng.choice(alt)] = 1
@@ -772,6 +794,50 @@ class StepChartGenerator:
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 2b: De-repeat red/blue runs (medium) ----------
if cfg['level'] >= 6 and subdiv in (8, 16):
step = 1 if subdiv == 8 else 2
last_gi = None
last_arrow = None
last_color = None
for m_idx, meas in enumerate(measures):
for r_idx, row in enumerate(meas):
if sum(row) != 1:
last_gi = None
last_arrow = None
last_color = None
continue
color = self._row_color(subdiv, r_idx)
if color not in ('red', 'blue'):
last_gi = None
last_arrow = None
last_color = None
continue
gi = m_idx * subdiv + r_idx
arrow = [i for i in range(4) if row[i]][0]
if (last_gi is not None
and gi == last_gi + step
and last_color is not None
and color != last_color
and arrow == last_arrow):
# Alternate arrows/sides to avoid repetitive red-blue runs
if arrow in self.LEFT_FOOT:
candidates = self.RIGHT_FOOT + [1, 2]
else:
candidates = self.LEFT_FOOT + [1, 2]
candidates = [a for a in candidates if a != arrow]
new_arrow = self.rng.choice(candidates)
row[:] = [0, 0, 0, 0]
row[new_arrow] = 1
arrow = new_arrow
last_gi = gi
last_arrow = arrow
last_color = color
# ---------- Rule 3: Add emphasis jumps on downbeats (med+ diffs) ----------
if cfg['level'] >= 5:
for m_idx, meas in enumerate(measures):
@@ -828,6 +894,53 @@ class StepChartGenerator:
else:
last_jump_gi = gi
# ---------- Rule 6: Hard diff yellow-note constraints ----------
if cfg['level'] == 8 and subdiv == 16:
# Limit to one yellow per measure
for m_idx, meas in enumerate(measures):
yellow_rows = []
for r_idx, row in enumerate(meas):
if sum(row) == 0:
continue
if self._row_color(subdiv, r_idx) == 'yellow':
t = offset + (m_idx * subdiv + r_idx) * spr
yellow_rows.append((r_idx, self.az.get_rms_at(t)))
if len(yellow_rows) > 1:
yellow_rows.sort(key=lambda x: x[1], reverse=True)
for r_idx, _ in yellow_rows[1:]:
meas[r_idx][:] = [0, 0, 0, 0]
# Yellow notes cannot be jumps
for meas in measures:
for r_idx, row in enumerate(meas):
if self._row_color(subdiv, r_idx) != 'yellow':
continue
if sum(row) >= 2:
arrows = [i for i in range(4) if row[i]]
keep = self.rng.choice(arrows)
row[:] = [0, 0, 0, 0]
row[keep] = 1
# Remove yellow notes adjacent to jumps
jump_map = []
for m_idx, meas in enumerate(measures):
for r_idx, row in enumerate(meas):
jump_map.append(sum(row) >= 2)
total_rows = len(jump_map)
for m_idx, meas in enumerate(measures):
for r_idx, row in enumerate(meas):
if sum(row) == 0:
continue
if self._row_color(subdiv, r_idx) != 'yellow':
continue
gi = m_idx * subdiv + r_idx
left_jump = gi > 0 and jump_map[gi - 1]
right_jump = gi + 1 < total_rows and jump_map[gi + 1]
if left_jump or right_jump:
row[:] = [0, 0, 0, 0]
return measures
def _smooth_run(self, flat, start, length):
@@ -876,6 +989,25 @@ class StepChartGenerator:
if ri >= 0 and abs((ot - offset) / spr - ri) < 0.45:
note_grid.add(ri)
# Medium: favor more red/blue pulses based on energy
if cfg['level'] == 6:
total_rows = n_meas * subdiv
for gi in range(total_rows):
if gi in note_grid:
continue
r_idx = gi % subdiv
color = self._row_color(subdiv, r_idx)
if color not in ('red', 'blue'):
continue
trow = offset + gi * spr
if trow < self.az.music_start or trow > self.az.duration:
continue
rms = self.az.get_rms_at(trow)
base = 0.18 if color == 'red' else 0.12
prob = base * min(1.0, rms / 0.6)
if self.rng.random() < prob:
note_grid.add(gi)
# density cap
max_notes = int(self.az.duration * cfg['max_nps'])
if len(note_grid) > max_notes:
@@ -891,7 +1023,8 @@ class StepChartGenerator:
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)
color = self._row_color(subdiv, r)
row, arrow = self._pick_arrow(trow, prev, cfg, color=color)
mrows.append(row)
prev.append(arrow)
prev = prev[-8:]
@@ -1046,6 +1179,10 @@ class App:
self.v_seed = tk.StringVar()
self.v_bpm = tk.StringVar() # manual BPM override
self.diff_vars: dict[str, tk.BooleanVar] = {}
self.last_dir = os.getcwd()
self._last_in = ""
self.v_in.trace_add('write', self._on_in_change)
self._build()
@@ -1118,22 +1255,52 @@ class App:
# ---- callbacks ----
def _browse_in(self):
p = filedialog.askopenfilename(title="Select Audio", filetypes=self.AUDIO_TYPES)
p = filedialog.askopenfilename(
title="Select Audio",
filetypes=self.AUDIO_TYPES,
initialdir=self.last_dir,
)
if p:
self.v_in.set(p)
self.v_out.set(str(Path(p).with_suffix('.sm')))
self._update_last_dir(p)
def _browse_out(self):
p = filedialog.asksaveasfilename(
title="Save .sm", defaultextension='.sm',
filetypes=[('StepMania', '*.sm'), ('All', '*.*')])
filetypes=[('StepMania', '*.sm'), ('All', '*.*')],
initialdir=self.last_dir)
if p:
self.v_out.set(p)
self._update_last_dir(p)
def _browse_vid(self):
p = filedialog.askopenfilename(title="Select Video", filetypes=self.VIDEO_TYPES)
p = filedialog.askopenfilename(
title="Select Video",
filetypes=self.VIDEO_TYPES,
initialdir=self.last_dir,
)
if p:
self.v_vid.set(p)
self._update_last_dir(p)
def _update_last_dir(self, path):
p = Path(path).expanduser()
try:
self.last_dir = str(p.resolve().parent)
except Exception:
self.last_dir = str(p.parent)
def _on_in_change(self, *_):
p = self.v_in.get().strip()
if not p:
return
self._update_last_dir(p)
prev_out = self.v_out.get().strip()
auto_out = str(Path(self._last_in).with_suffix('.sm')) if self._last_in else ""
if not prev_out or prev_out == auto_out:
self.v_out.set(str(Path(p).with_suffix('.sm')))
self._last_in = p
def _log(self, msg, pct=None):
def _do():