strudel/kisum/kisum.py
hacklab 7ace286d8a
Some checks are pending
Build and Deploy to warm (strudel.cc) / build (push) Waiting to run
Strudel tests / build (20) (push) Waiting to run
Integra KISUM (audio→Strudel) + pestaña Visor + accesos en la cabecera
- 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>
2026-07-26 18:31:37 +02:00

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()