Better difficulties, no bias for bass
This commit is contained in:
157
stepmania-ddr/README.md
Normal file
157
stepmania-ddr/README.md
Normal 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).
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, filedialog, messagebox, scrolledtext
|
||||
import os
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user