- Pestaña KISUM: sube audio/vídeo y lo convierte en código Strudel vía un mini-servidor Python local (kisum/, puerto 3010) que arranca el lanzador. - Pestaña Visor: piano-roll (pentagrama) + osciloscopio + espectro en vivo de lo que suena, sin escribir .pianoroll()/.scope(). - Botones en la cabecera: projects, KISUM, visor, ajustes. - KISUM empaquetado en kisum/ (código, sin venv) + install.sh de un comando y lanzador portable (sin rutas absolutas). - .gitignore excluye venv/cachés/logs de KISUM. Docs en README. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
221 lines
7.8 KiB
Python
221 lines
7.8 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
KISUM - musik al reves.
|
|
|
|
Escucha un tema de audio (o el audio de un video) y lo devuelve como codigo
|
|
Strudel: tempo, bateria por bandas (bombo/caja/hats) y, opcionalmente, una
|
|
linea de bajo aproximada.
|
|
|
|
Acepta cualquier archivo con audio: mp3, wav, flac, ogg, m4a... y tambien
|
|
video (mp4, mkv, webm, mov...). Si el formato no es audio puro, extrae la
|
|
pista con ffmpeg automaticamente.
|
|
|
|
Uso:
|
|
python3 kisum.py tema.mp3 [--bars 4] [--bass] [-o salida.strudel]
|
|
python3 kisum.py clip.mp4 # tambien vale un video
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import warnings
|
|
|
|
import numpy as np
|
|
|
|
try:
|
|
import librosa
|
|
import soundfile as sf
|
|
except ImportError:
|
|
sys.exit("Faltan dependencias. Instalalas:\n"
|
|
" python3 -m venv .venv && "
|
|
".venv/bin/pip install -r requirements.txt")
|
|
|
|
STEPS_PER_BEAT = 4 # semicorcheas
|
|
BEATS_PER_BAR = 4 # asumimos 4/4
|
|
STEPS_PER_BAR = STEPS_PER_BEAT * BEATS_PER_BAR
|
|
|
|
# Bandas de frecuencia -> sample de Strudel
|
|
BANDS = [
|
|
("bd", 20, 150), # bombo
|
|
("sd", 150, 2500), # caja / palmas
|
|
("hh", 5000, 11000), # hats / brillos
|
|
]
|
|
|
|
NOTE_NAMES = ["c", "cs", "d", "eb", "e", "f",
|
|
"fs", "g", "gs", "a", "bb", "b"]
|
|
|
|
|
|
def load_audio(path):
|
|
"""Carga el audio de `path` como (senal mono float32, sr).
|
|
|
|
Primero intenta leerlo directamente con libsndfile (wav, flac, ogg,
|
|
mp3...). Si no es un formato de audio que entienda (un video mp4/mkv/
|
|
webm/mov, u otro contenedor), extrae la pista de audio con ffmpeg a un
|
|
wav temporal. Asi KISUM acepta cualquier archivo con audio sin depender
|
|
del backend audioread (deprecado en librosa)."""
|
|
if not os.path.exists(path):
|
|
sys.exit(f"No existe el archivo: {path}")
|
|
|
|
# 1) intento directo: wav/flac/ogg/mp3 via libsndfile
|
|
try:
|
|
y, sr = sf.read(path, dtype="float32", always_2d=True)
|
|
return y.mean(axis=1), sr # mezcla a mono
|
|
except Exception:
|
|
pass # no es audio legible -> ffmpeg
|
|
|
|
# 2) fallback: extraer audio con ffmpeg (videos y formatos raros)
|
|
if not shutil.which("ffmpeg"):
|
|
sys.exit(f"No puedo leer '{path}' directamente y no encuentro ffmpeg.\n"
|
|
"Instala ffmpeg (sudo apt install ffmpeg) o convierte el "
|
|
"archivo a wav/mp3.")
|
|
|
|
tmp_wav = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
|
|
try:
|
|
res = subprocess.run(
|
|
["ffmpeg", "-y", "-loglevel", "error", "-i", path,
|
|
"-vn", "-ac", "1", "-ar", "44100", tmp_wav],
|
|
capture_output=True, text=True)
|
|
if res.returncode != 0 or not os.path.getsize(tmp_wav):
|
|
sys.exit(f"ffmpeg no pudo extraer audio de '{path}'.\n"
|
|
f"{res.stderr.strip()}")
|
|
y, sr = sf.read(tmp_wav, dtype="float32", always_2d=True)
|
|
return y.mean(axis=1), sr
|
|
finally:
|
|
if os.path.exists(tmp_wav):
|
|
os.remove(tmp_wav)
|
|
|
|
|
|
def band_onsets(y, sr, fmin, fmax):
|
|
"""Tiempos (s) de los golpes dentro de una banda de frecuencias."""
|
|
with warnings.catch_warnings():
|
|
# las bandas son estrechas: sobran filtros mel y librosa avisa.
|
|
# Es inofensivo (esos canales quedan vacios), lo silenciamos.
|
|
warnings.filterwarnings("ignore", message="Empty filters detected")
|
|
S = librosa.feature.melspectrogram(
|
|
y=y, sr=sr, n_mels=32, fmin=fmin, fmax=fmax)
|
|
env = librosa.onset.onset_strength(S=librosa.power_to_db(S), sr=sr)
|
|
frames = librosa.onset.onset_detect(
|
|
onset_envelope=env, sr=sr, backtrack=False)
|
|
return librosa.frames_to_time(frames, sr=sr)
|
|
|
|
|
|
def make_grid(beat_times, bars):
|
|
"""Rejilla de semicorcheas: subdivide cada pulso en 4. Devuelve los
|
|
tiempos (s) de cada paso, recortados a `bars` compases completos."""
|
|
steps = []
|
|
for i in range(len(beat_times) - 1):
|
|
t0, t1 = beat_times[i], beat_times[i + 1]
|
|
for k in range(STEPS_PER_BEAT):
|
|
steps.append(t0 + (t1 - t0) * k / STEPS_PER_BEAT)
|
|
return steps[: bars * STEPS_PER_BAR]
|
|
|
|
|
|
def quantize(onsets, grid):
|
|
"""Marca en que paso de la rejilla cae cada golpe (al mas cercano,
|
|
con tolerancia de medio paso)."""
|
|
hits = [False] * len(grid)
|
|
if not len(grid):
|
|
return hits
|
|
step = np.median(np.diff(grid)) if len(grid) > 1 else 0.1
|
|
for t in onsets:
|
|
i = int(np.argmin(np.abs(np.asarray(grid) - t)))
|
|
if abs(grid[i] - t) <= step / 2:
|
|
hits[i] = True
|
|
return hits
|
|
|
|
|
|
def pattern_string(hits, token):
|
|
"""Convierte la lista de pasos en mini-notacion Strudel: un compas por
|
|
[ ] y, si hay varios, alternando con < >."""
|
|
bars = []
|
|
for b in range(0, len(hits), STEPS_PER_BAR):
|
|
bar = hits[b:b + STEPS_PER_BAR]
|
|
if len(bar) < STEPS_PER_BAR:
|
|
break
|
|
bars.append("[" + " ".join(token if h else "~" for h in bar) + "]")
|
|
if not bars:
|
|
return None
|
|
if len(bars) == 1:
|
|
return bars[0][1:-1] # sin corchetes si es uno
|
|
if all(b == bars[0] for b in bars):
|
|
return bars[0][1:-1] # todos iguales: uno basta
|
|
return "<" + " ".join(bars) + ">"
|
|
|
|
|
|
def bass_line(y, sr, beat_times, bars):
|
|
"""Nota dominante por pulso sobre el audio grave (experimental)."""
|
|
chroma = librosa.feature.chroma_cqt(y=y, sr=sr, fmin=32.7) # desde C1
|
|
beats = librosa.time_to_frames(beat_times, sr=sr)
|
|
notes = []
|
|
for i in range(min(len(beats) - 1, bars * BEATS_PER_BAR)):
|
|
seg = chroma[:, beats[i]:beats[i + 1]]
|
|
if seg.size == 0 or seg.max() < 0.5:
|
|
notes.append("~")
|
|
else:
|
|
notes.append(NOTE_NAMES[int(seg.mean(axis=1).argmax())] + "2")
|
|
if not notes or all(n == "~" for n in notes):
|
|
return None
|
|
return " ".join(notes)
|
|
|
|
|
|
def analyze(path, bars, want_bass):
|
|
y, sr = load_audio(path)
|
|
tempo, beat_times = librosa.beat.beat_track(y=y, sr=sr, units="time")
|
|
tempo = float(np.atleast_1d(tempo)[0])
|
|
if len(beat_times) < BEATS_PER_BAR + 1:
|
|
sys.exit("No encuentro un pulso claro en el audio. "
|
|
"Prueba con un fragmento con percusion.")
|
|
grid = make_grid(beat_times, bars)
|
|
|
|
lines = []
|
|
for token, fmin, fmax in BANDS:
|
|
hits = quantize(band_onsets(y, sr, fmin, fmax), grid)
|
|
pat = pattern_string(hits, token)
|
|
if pat:
|
|
gain = '.gain(0.7)' if token == "hh" else ""
|
|
lines.append(f' s("{pat}"){gain},')
|
|
|
|
if want_bass:
|
|
bl = bass_line(y, sr, beat_times, bars)
|
|
if bl:
|
|
lines.append(f' note("{bl}").s("sawtooth").lpf(400)'
|
|
f'.gain(0.6), // bajo experimental')
|
|
|
|
bpm = round(tempo, 1)
|
|
code = [
|
|
f"// KISUM v0 :: {path}",
|
|
f"// bpm detectado ~ {bpm} (si suena a mitad/doble, corrige aqui)",
|
|
f"setcps({bpm}/60/4)",
|
|
"stack(",
|
|
*lines,
|
|
")",
|
|
]
|
|
return "\n".join(code) + "\n"
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(
|
|
description="KISUM: de un tema de audio a codigo Strudel.")
|
|
ap.add_argument("audio",
|
|
help="archivo con audio: mp3, wav, flac, m4a, ogg... "
|
|
"o video (mp4, mkv, webm, mov...)")
|
|
ap.add_argument("--bars", type=int, default=4,
|
|
help="compases a transcribir (defecto: 4)")
|
|
ap.add_argument("--bass", action="store_true",
|
|
help="anade linea de bajo aproximada")
|
|
ap.add_argument("-o", "--out", help="guardar tambien en un archivo")
|
|
args = ap.parse_args()
|
|
|
|
code = analyze(args.audio, max(1, args.bars), args.bass)
|
|
print(code)
|
|
if args.out:
|
|
with open(args.out, "w", encoding="utf-8") as fh:
|
|
fh.write(code)
|
|
print(f"// guardado en {args.out}", file=sys.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|