Integra KISUM (audio→Strudel) + pestaña Visor + accesos en la cabecera
Some checks are pending
Build and Deploy to warm (strudel.cc) / build (push) Waiting to run
Strudel tests / build (20) (push) Waiting to run

- 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>
This commit is contained in:
hacklab 2026-07-26 18:31:37 +02:00
parent 970b89b08c
commit 7ace286d8a
13 changed files with 1052 additions and 13 deletions

63
kisum/README.md Normal file
View file

@ -0,0 +1,63 @@
# KISUM
*musik al revés*: le pasas un tema de audio (o el audio de un vídeo) y
KISUM lo escucha, saca el tempo y los golpes, y te lo devuelve como
**código Strudel** listo para pegar en tu Strudel local (`localhost:3009`)
y seguir tocándolo en vivo.
```
tema.mp3 / clip.mp4 ──> análisis (tempo, onsets por bandas, bajo) ──> patrón ──> Strudel
```
**Acepta cualquier archivo con audio**: `mp3`, `wav`, `flac`, `m4a`, `ogg`...
y también vídeo (`mp4`, `mkv`, `webm`, `mov`...). Los formatos de audio se
leen directos; para vídeo u otros contenedores extrae la pista con `ffmpeg`
de forma automática (necesitas tener `ffmpeg` instalado).
## Instalación
```bash
cd ~/COFRE/CODERS/MUSIKA/AUDIO/PROYECTOS/KISUM
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
```
## Uso
```bash
.venv/bin/python3 kisum.py tema.mp3 # imprime el código Strudel
.venv/bin/python3 kisum.py clip.mp4 # vale también un vídeo
.venv/bin/python3 kisum.py tema.mp3 --bars 8 # analiza 8 compases
.venv/bin/python3 kisum.py tema.mp3 --bass # añade línea de bajo (experimental)
.venv/bin/python3 kisum.py tema.mp3 -o tema.strudel
```
## Cómo funciona (v0)
1. **Tempo y rejilla**`librosa.beat.beat_track` saca el BPM y los tiempos
de negra; cada negra se parte en 4 (semicorcheas) → rejilla de 16 pasos
por compás (4/4).
2. **Golpes por bandas** — detección de onsets en tres bandas:
graves (20150 Hz → `bd`), medios (1502500 Hz → `sd`),
agudos (511 kHz → `hh`).
3. **Cuantización** — cada golpe cae en el paso más cercano de la rejilla.
4. **Salida** — un `stack()` de patrones Strudel, un compás entre `[ ]` y
los compases alternando con `< >`.
5. **`--bass`** — croma por pulso sobre el audio grave → `note("...")`.
Es aproximado: úsalo como punto de partida, no como transcripción.
## Limitaciones conocidas de la v0
- Asume 4/4 y tempo estable (techno/house van perfectos; jazz no).
- No separa instrumentos: un golpe grave "es" un bombo aunque fuera otra
cosa. La separación por stems (demucs) está en la hoja de ruta.
- El BPM puede salir al doble o a la mitad; corrígelo en el `setcps`.
## Hoja de ruta
- [x] v0: batería por bandas + tempo → Strudel (este directorio)
- [x] Soporte universal de archivos: audio + vídeo (extracción con ffmpeg)
- [ ] Separación por stems (demucs) y transcripción por stem
- [ ] Melodía/acordes (basic-pitch) → `note()` con más de una voz
- [ ] Elegir samples de Strudel según el timbre detectado
- [ ] Modo directo: escuchar por el micro/monitor y transcribir en vivo

27
kisum/install.sh Executable file
View file

@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Instala el entorno de KISUM (Python + librosa) en ./.venv de esta carpeta.
# Portable: no depende de rutas absolutas, funciona en cualquier Linux.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$DIR"
echo "==> KISUM: comprobando requisitos"
command -v python3 >/dev/null 2>&1 || {
echo "ERROR: falta python3. Instálalo con el gestor de tu distro." >&2
exit 1
}
if ! command -v ffmpeg >/dev/null 2>&1; then
echo "AVISO: ffmpeg no está instalado. KISUM lo necesita para leer vídeo"
echo " (mp4/mkv...) y algunos audios. Instálalo:"
echo " Debian/Ubuntu: sudo apt install ffmpeg"
echo " Fedora: sudo dnf install ffmpeg"
echo " Arch: sudo pacman -S ffmpeg"
fi
echo "==> KISUM: creando entorno virtual (.venv)"
python3 -m venv .venv
./.venv/bin/pip install --upgrade pip -q
echo "==> KISUM: instalando dependencias (librosa, soundfile, numpy)…"
./.venv/bin/pip install -r requirements.txt
echo "==> KISUM listo. Prueba: ./kisum tu_cancion.mp3"

27
kisum/kisum Executable file
View file

@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Lanzador de KISUM: ejecuta kisum.py con su propio venv, desde donde sea.
#
# ./kisum archivo.mp4
# ./kisum archivo.mp4 --bars 8 --bass
# ./kisum ~/Downloads/cancion.wav -o cancion.strudel
#
set -euo pipefail
# Directorio real de este script (resolviendo symlinks, por si esta en el PATH)
SOURCE="${BASH_SOURCE[0]}"
while [ -h "$SOURCE" ]; do
DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
SOURCE="$(readlink "$SOURCE")"
[[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
done
DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
PY="$DIR/.venv/bin/python3"
if [ ! -x "$PY" ]; then
echo "KISUM: no encuentro el entorno (.venv)." >&2
echo "Montalo con:" >&2
echo " cd \"$DIR\" && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt" >&2
exit 1
fi
exec "$PY" "$DIR/kisum.py" "$@"

221
kisum/kisum.py Normal file
View file

@ -0,0 +1,221 @@
#!/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()

120
kisum/kisum_server.py Normal file
View file

@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Mini-servidor local de KISUM para integrarlo en Strudel.
Expone un endpoint HTTP que recibe un archivo de audio/video, lo analiza con
kisum.analyze() y devuelve el codigo Strudel en JSON. Pensado para correr en
local (127.0.0.1) junto a Strudel; lo arranca strudel-launcher.sh.
python3 kisum_server.py # escucha en 127.0.0.1:3010
KISUM_PORT=7331 python3 kisum_server.py
Endpoints:
GET /health -> {"ok": true}
POST /analyze?bars=4&bass=1 (cuerpo = bytes del archivo,
cabecera X-Filename: nombre.mp4)
-> {"code": "<codigo strudel>", "filename": ...}
"""
import json
import os
import sys
import tempfile
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
HERE = os.path.dirname(os.path.abspath(__file__))
if HERE not in sys.path:
sys.path.insert(0, HERE)
# Import pesado (librosa) al arrancar: asi la primera peticion ya es rapida.
import kisum # noqa: E402
PORT = int(os.environ.get("KISUM_PORT", "3010"))
def _cors(handler):
handler.send_header("Access-Control-Allow-Origin", "*")
handler.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
handler.send_header("Access-Control-Allow-Headers", "Content-Type, X-Filename")
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a):
pass # silencio: el log lo lleva el launcher
def _json(self, code, obj):
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
_cors(self)
self.end_headers()
self.wfile.write(body)
def do_OPTIONS(self):
self.send_response(204)
_cors(self)
self.end_headers()
def do_GET(self):
path = urlparse(self.path).path
if path in ("/health", "/"):
self._json(200, {"ok": True, "service": "kisum"})
else:
self._json(404, {"error": "not found"})
def do_POST(self):
parsed = urlparse(self.path)
if parsed.path != "/analyze":
self._json(404, {"error": "not found"})
return
qs = parse_qs(parsed.query)
try:
bars = max(1, int(qs.get("bars", ["4"])[0]))
except ValueError:
bars = 4
want_bass = qs.get("bass", ["0"])[0] in ("1", "true", "yes", "on")
filename = self.headers.get("X-Filename", "audio")
ext = os.path.splitext(filename)[1] or ".bin"
length = int(self.headers.get("Content-Length", "0") or 0)
if length <= 0:
self._json(400, {"error": "archivo vacio"})
return
data = self.rfile.read(length)
tmp = tempfile.NamedTemporaryFile(suffix=ext, delete=False)
tmp.write(data)
tmp.close()
try:
code = kisum.analyze(tmp.name, bars, want_bass)
# el comentario de cabecera lleva la ruta temporal: la cambiamos
# por el nombre real del archivo que subio el usuario
code = code.replace(tmp.name, filename)
self._json(200, {"code": code, "filename": filename})
except SystemExit as e:
# kisum.analyze() hace sys.exit(msg) cuando no encuentra pulso, etc.
msg = str(e.code) if e.code else "no se pudo analizar el audio"
self._json(422, {"error": msg})
except Exception as e: # noqa: BLE001
self._json(500, {"error": f"{type(e).__name__}: {e}"})
finally:
try:
os.remove(tmp.name)
except OSError:
pass
def main():
srv = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
print(f"KISUM server escuchando en http://127.0.0.1:{PORT}", flush=True)
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()

3
kisum/requirements.txt Normal file
View file

@ -0,0 +1,3 @@
librosa>=0.10
soundfile
numpy