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

6
.gitignore vendored
View file

@ -149,3 +149,9 @@ samples/*
# --- fork hacklab: material personal que no se publica ---
my-patterns/
strudel-server.log
# KISUM (integración audio → Strudel): entorno y cachés locales, nunca al repo
kisum-server.log
kisum/.venv/
kisum/__pycache__/
*.pyc

View file

@ -18,30 +18,41 @@ pinchar en directo. Misma licencia que upstream: AGPL-3.0 (ver `LICENSE`).
scheduler). Al cargar un track el loop arranca desde cero; editar y
actualizar en vivo no reinicia nada.
- Pestaña Guía: chuleta de síntesis y sonidos.
- Pestaña **KISUM** (*musik al revés*): sube un audio o vídeo y te lo devuelve
como código Strudel (tempo + batería por bandas). Lo procesa un mini-servidor
Python local (`kisum/`) que arranca solo con el lanzador. Ver más abajo.
- Pestaña **Visor**: pentagrama (piano-roll), onda (osciloscopio) y espectro
**en vivo** de lo que suena, sin tener que escribir `.pianoroll()` ni
`.scope()` en el código.
- Accesos directos en la cabecera: `projects`, `KISUM`, `visor`, `ajustes`.
- Packs de temas del editor: neon-madrid y hacklab-pack.
- Lanzador de escritorio: `strudel-launcher.sh`.
## Instalación (Linux)
Requisitos: `git`, `node` >= 20 y `pnpm` (`corepack enable`).
Requisitos: `git`, `node` >= 20, `pnpm` (`corepack enable`) y, para KISUM,
`python3` + `ffmpeg`.
**Fácil (un comando):**
```bash
git clone https://gitea.laenre.net/hacklab/strudel.git
cd strudel
./install.sh # instala Node deps + build + entorno de KISUM
./strudel-launcher.sh # http://127.0.0.1:3009 (+ KISUM en 3010) y abre el navegador
```
El `install.sh` no descarga los paquetes al repo: usa `pnpm` (Node) y un
`venv` de Python para KISUM. Todo eso queda en `.gitignore`.
**Manual / desarrollo:**
```bash
pnpm i
```
Desarrollo (recarga en vivo):
```bash
pnpm dev # http://localhost:4321
```
Uso diario (build estática + lanzador):
```bash
pnpm dev # recarga en vivo, http://localhost:4321
# o, para el uso diario (build estática + lanzador):
pnpm build
./strudel-launcher.sh # sirve http://127.0.0.1:3009 y abre el navegador
./strudel-launcher.sh
```
Icono de escritorio: `~/.local/share/applications/strudel.desktop`
@ -65,6 +76,32 @@ personal crea un módulo con el mismo formato `{name, folder, code}` y
concaténalo en `DEFAULT_PROJECTS` (los packs personales están en
`.gitignore`). También se pueden crear proyectos desde la propia pestaña.
## KISUM (audio → código Strudel)
KISUM escucha una canción (audio **o vídeo**: mp3, wav, flac, m4a, mp4, mkv…),
detecta el tempo y los golpes por bandas (bombo/caja/hats) y lo devuelve como
código Strudel listo para pegar. Vive en la carpeta `kisum/` (Python + librosa)
y se usa de dos formas:
- **Desde Strudel**: pestaña *KISUM* → arrastra el archivo → *Analizar*
*Cargar en editor*. El mini-servidor (`kisum/kisum_server.py`, en
`127.0.0.1:3010`) lo arranca solo `strudel-launcher.sh`.
- **Desde la terminal**:
```bash
cd kisum
./kisum tu_cancion.mp3 # imprime el código Strudel
./kisum clip.mp4 --bars 8 --bass # vídeo, 8 compases, con línea de bajo
```
Requiere `python3` y `ffmpeg`. Si `./install.sh` no lo dejó listo (o quieres
reinstalarlo): `./kisum/install.sh`. Detalles y limitaciones en
[`kisum/README.md`](./kisum/README.md).
> Nota: la transcripción de la v0 analiza la mezcla completa (aún no separa
> instrumentos), así que en temas muy densos el patrón sale cargado. La
> separación por stems (demucs) está en la hoja de ruta.
## Actualizar desde upstream
```bash

51
install.sh Executable file
View file

@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Instalador todo-en-uno del fork de Strudel (hacklab) + KISUM, para Linux.
# NO descarga nada raro: usa pnpm para Node y venv+pip para KISUM.
#
# git clone https://gitea.laenre.net/hacklab/strudel.git
# cd strudel && ./install.sh && ./strudel-launcher.sh
#
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$DIR"
echo "════════════════════════════════════════════"
echo " Instalador Strudel (fork hacklab) + KISUM"
echo "════════════════════════════════════════════"
# ── Node / pnpm ─────────────────────────────────────────────────────
# Si node no está en el PATH pero hay nvm, lo usamos.
if ! command -v node >/dev/null 2>&1 && [ -d "$HOME/.nvm/versions/node" ]; then
NODE_BIN="$(ls -d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)"
[ -n "${NODE_BIN:-}" ] && export PATH="${NODE_BIN}:${PATH}"
fi
if ! command -v node >/dev/null 2>&1; then
echo "ERROR: falta Node.js (>= 20)." >&2
echo " Instálalo con nvm: https://github.com/nvm-sh/nvm" >&2
echo " nvm install 20 && nvm use 20" >&2
exit 1
fi
if ! command -v pnpm >/dev/null 2>&1; then
echo "==> activando pnpm con corepack"
corepack enable pnpm 2>/dev/null || {
echo "ERROR: no pude activar pnpm. Instálalo: npm i -g pnpm" >&2
exit 1
}
fi
echo "==> Strudel: instalando dependencias de Node (pnpm i)…"
pnpm i
echo "==> Strudel: construyendo la web (pnpm build)…"
pnpm build
# ── KISUM (opcional; si falla, Strudel funciona igual) ──────────────
if [ -f "$DIR/kisum/install.sh" ]; then
echo "==> Configurando KISUM (audio → código Strudel)…"
bash "$DIR/kisum/install.sh" || echo "AVISO: KISUM no se instaló del todo. Strudel funciona igual; revisa python3/ffmpeg."
fi
echo
echo "✔ Instalación completa."
echo " Arranca con: ./strudel-launcher.sh"
echo " Strudel: http://127.0.0.1:3009"
echo " KISUM server: http://127.0.0.1:3010 (arranca solo con el lanzador)"

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

View file

@ -39,5 +39,29 @@ if ! curl -fsS -o /dev/null "${URL}" 2>/dev/null; then
done
fi
# ── Servidor KISUM (audio → código Strudel, integrado en la pestaña KISUM) ──
# Arranca el mini-servidor Python si existe su venv y no está ya escuchando.
# No es crítico: si falta, Strudel abre igual (la pestaña avisará).
# Busca KISUM eligiendo la primera copia que TENGA entorno (.venv):
# 1) variable KISUM_DIR (si la defines tú)
# 2) empaquetado en el repo (kisum/) ← tras ./install.sh
# 3) ruta externa del hub MUSIKA (instalación antigua)
# Si ninguna tiene venv aún, usa el empaquetado (lo tendrá tras ./install.sh).
if [ -z "${KISUM_DIR:-}" ]; then
for cand in "${DIR}/kisum" "${HOME}/COFRE/CODERS/MUSIKA/AUDIO/PROYECTOS/KISUM"; do
if [ -x "${cand}/.venv/bin/python3" ]; then
KISUM_DIR="${cand}"
break
fi
done
KISUM_DIR="${KISUM_DIR:-${DIR}/kisum}"
fi
KISUM_PORT="${KISUM_PORT:-3010}"
KISUM_PY="${KISUM_DIR}/.venv/bin/python3"
if [ -x "${KISUM_PY}" ] && ! curl -fsS -o /dev/null "http://127.0.0.1:${KISUM_PORT}/health" 2>/dev/null; then
KISUM_PORT="${KISUM_PORT}" setsid "${KISUM_PY}" "${KISUM_DIR}/kisum_server.py" \
> "${DIR}/kisum-server.log" 2>&1 &
fi
# Abre en el navegador por defecto
xdg-open "${URL}" >/dev/null 2>&1 &

View file

@ -0,0 +1,196 @@
import { useEffect, useRef, useState } from 'react';
import cx from '@src/cx.mjs';
import { useSettings } from '@src/settings.mjs';
import { ActionButton } from '../button/action-button.jsx';
// Pestaña KISUM: "musik al reves". Arrastras un audio o video, se lo manda al
// mini-servidor local de KISUM (Python), y devuelve el codigo Strudel listo
// para cargar en el editor. El servidor lo arranca strudel-launcher.sh.
const KISUM_URL = 'http://127.0.0.1:3010';
export function KisumTab({ context }) {
const { fontFamily } = useSettings();
const [file, setFile] = useState(null);
const [bars, setBars] = useState(4);
const [bass, setBass] = useState(false);
const [code, setCode] = useState('');
const [status, setStatus] = useState('idle'); // idle | loading | done | error
const [error, setError] = useState('');
const [serverUp, setServerUp] = useState(null); // null=comprobando, true, false
const [copied, setCopied] = useState(false);
const [drag, setDrag] = useState(false);
const inputRef = useRef(null);
const editor = () => context.editorRef?.current;
// comprobar si el servidor KISUM responde (y reintentar cada pocos segundos)
useEffect(() => {
let alive = true;
const check = () =>
fetch(`${KISUM_URL}/health`)
.then((r) => r.ok)
.then((ok) => alive && setServerUp(ok))
.catch(() => alive && setServerUp(false));
check();
const id = setInterval(check, 4000);
return () => {
alive = false;
clearInterval(id);
};
}, []);
const analyze = async () => {
if (!file) return;
setStatus('loading');
setError('');
setCode('');
try {
const q = `?bars=${bars}&bass=${bass ? 1 : 0}`;
const res = await fetch(`${KISUM_URL}/analyze${q}`, {
method: 'POST',
headers: { 'X-Filename': file.name, 'Content-Type': 'application/octet-stream' },
body: file,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `error ${res.status}`);
setCode(data.code);
setStatus('done');
} catch (e) {
setError(String(e.message || e));
setStatus('error');
}
};
const loadInEditor = () => {
const ed = editor();
if (!ed || !code) return;
ed.setCode(code);
};
const copy = () => {
navigator.clipboard?.writeText?.(code);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
};
const onDrop = (e) => {
e.preventDefault();
setDrag(false);
const f = e.dataTransfer.files?.[0];
if (f) setFile(f);
};
return (
<div className="w-full h-full text-foreground flex flex-col overflow-hidden" style={{ fontFamily }}>
{/* estado del servidor */}
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-muted shrink-0 text-xs">
<span
className={cx(
'w-2 h-2 rounded-full',
serverUp === true ? 'bg-green-400' : serverUp === false ? 'bg-red-400' : 'bg-yellow-400',
)}
/>
<span className="opacity-70">
{serverUp === true
? 'servidor KISUM conectado'
: serverUp === false
? 'servidor KISUM apagado — reabre Strudel con el icono, o arráncalo a mano'
: 'buscando servidor KISUM…'}
</span>
</div>
{/* zona de soltar archivo */}
<div className="px-2 py-2 shrink-0">
<div
onClick={() => inputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
setDrag(true);
}}
onDragLeave={() => setDrag(false)}
onDrop={onDrop}
className={cx(
'border-2 border-dashed rounded-lg px-3 py-6 text-center cursor-pointer transition-colors',
drag ? 'border-cyan-400 bg-cyan-400/10' : 'border-muted hover:border-cyan-400/60',
)}
>
{file ? (
<span className="text-sm">
<b>{file.name}</b>
</span>
) : (
<span className="text-sm opacity-70">arrastra aquí un audio o vídeo (mp3, wav, mp4, mkv) o haz clic</span>
)}
<input
ref={inputRef}
type="file"
accept="audio/*,video/*"
className="hidden"
onChange={(e) => setFile(e.target.files?.[0] || null)}
/>
</div>
</div>
{/* opciones */}
<div className="flex items-center gap-4 px-2 pb-2 shrink-0 text-sm flex-wrap">
<label className="flex items-center gap-1.5">
<span className="opacity-70">compases</span>
<input
type="number"
min="1"
max="32"
value={bars}
onChange={(e) => setBars(Math.max(1, Number(e.target.value) || 1))}
className="w-16 bg-lineHighlight border border-muted rounded px-1.5 py-0.5 outline-none focus:border-cyan-400"
/>
</label>
<label className="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" checked={bass} onChange={(e) => setBass(e.target.checked)} className="accent-cyan-400" />
<span className="opacity-70">línea de bajo (experimental)</span>
</label>
<ActionButton
label={status === 'loading' ? 'analizando…' : 'Analizar'}
onClick={analyze}
className={cx(
'ml-auto px-3 py-1 border rounded font-bold',
!file || status === 'loading' || serverUp !== true
? 'border-muted opacity-40 pointer-events-none'
: 'border-cyan-400/70 text-cyan-300 hover:bg-cyan-400/10',
)}
/>
</div>
{/* resultado */}
<div className="grow overflow-auto px-2 py-2 min-h-0 border-t border-muted">
{status === 'error' && <p className="text-red-400 text-sm whitespace-pre-wrap">{error}</p>}
{status === 'loading' && <p className="opacity-60 text-sm">escuchando la canción y sacando el ritmo</p>}
{status === 'done' && code && (
<>
<div className="flex items-center gap-2 mb-2">
<ActionButton
label="Cargar en editor"
onClick={loadInEditor}
className="text-cyan-300 border border-cyan-400/60 rounded px-2 py-0.5 text-sm hover:bg-cyan-400/10"
/>
<ActionButton
label={copied ? 'copiado' : 'copiar'}
onClick={copy}
className={cx(
'border rounded px-2 py-0.5 text-sm',
copied ? 'border-green-400 text-green-400' : 'border-muted hover:border-cyan-400/60',
)}
/>
</div>
<pre className="font-mono text-xs whitespace-pre-wrap leading-snug opacity-90 select-text">{code}</pre>
</>
)}
{status === 'idle' && (
<p className="opacity-50 text-xs">
KISUM escucha la canción, detecta el tempo y los golpes (bombo/caja/hats) y te lo devuelve como código
Strudel. Sube un archivo y pulsa Analizar.
</p>
)}
</div>
</div>
);
}

View file

@ -13,6 +13,8 @@ import { FilesTab } from './FilesTab';
import { PatternsTab } from './PatternsTab';
import { ProjectsTab } from './ProjectsTab';
import { GuideTab } from './GuideTab';
import { KisumTab } from './KisumTab';
import { VisorTab } from './VisorTab';
import { Reference } from './Reference';
import { SettingsTab } from './SettingsTab';
import { SoundsTab } from './SoundsTab';
@ -198,6 +200,11 @@ function MainMenu({ context, isEmbedded = false, className }) {
const { started, pending, isDirty, activeCode, handleTogglePlay, handleEvaluate, handleShare } = context;
const { isCSSAnimationDisabled } = useSettings();
const t = useT();
// abre el panel lateral en la pestaña indicada (projects, kisum, visor, settings)
const openTab = (tab) => {
setTab(tab);
setIsPanelOpened(true);
};
return (
<div
className={cx('flex items-center gap-1 text-sm max-w-full shrink-0 overflow-hidden text-foreground', className)}
@ -227,6 +234,28 @@ function MainMenu({ context, isEmbedded = false, className }) {
<span>{t('learn')}</span>
</a>
)}
{/* accesos directos a paneles (abren la pestaña correspondiente) */}
{!isEmbedded && <span className="mx-1 h-6 w-px bg-muted/70 self-center" />}
{!isEmbedded && (
<button title="projects" className={menuBtn} onClick={() => openTab('projects')}>
<span>projects</span>
</button>
)}
{!isEmbedded && (
<button title="KISUM: audio a código Strudel" className={menuBtn} onClick={() => openTab('kisum')}>
<span>KISUM</span>
</button>
)}
{!isEmbedded && (
<button title="visor: pentagrama y ondas en vivo" className={menuBtn} onClick={() => openTab('visor')}>
<span>visor</span>
</button>
)}
{!isEmbedded && (
<button title="ajustes" className={menuBtn} onClick={() => openTab('settings')}>
<span>ajustes</span>
</button>
)}
</div>
);
}
@ -378,6 +407,8 @@ export function RightPanel({ context }) {
const tabNames = {
projects: 'projects',
kisum: 'kisum',
visor: 'visor',
guía: 'guia',
welcome: 'intro',
patterns: 'patterns',
@ -429,6 +460,10 @@ function PanelContent({ context, tab }) {
switch (tab) {
case tabNames.projects:
return <ProjectsTab context={context} />;
case tabNames.kisum:
return <KisumTab context={context} />;
case tabNames.visor:
return <VisorTab context={context} />;
case tabNames.guia:
return <GuideTab />;
case tabNames.patterns:

View file

@ -0,0 +1,229 @@
import { useEffect, useRef, useState } from 'react';
import cx from '@src/cx.mjs';
import { useSettings } from '@src/settings.mjs';
import { __pianoroll } from '@strudel/draw';
import {
drawTimeScope,
drawFrequencyScope,
getAudioContext,
getAnalyserById,
getSuperdoughAudioController,
analysers,
} from '@strudel/webaudio';
// Visor: dibuja EN VIVO lo que suena, sin tener que escribir nada en el codigo.
// - PENTAGRAMA (piano-roll): rejilla de notas de lo que se esta tocando ahora.
// - ONDA (osciloscopio): forma de onda del sonido en tiempo real.
// - ESPECTRO: reparto de frecuencias (graves -> agudos).
// El truco para que funcione con CUALQUIER codigo (sin .scope()) es pinchar un
// AnalyserNode al bus maestro de superdough (destinationGain).
const CKEY = 'strudel-visor-cycles'; // zoom del pentagrama (compases visibles)
const SCOPE_ID = 'visor';
export function VisorTab({ context }) {
const { fontFamily } = useSettings();
const rollRef = useRef(null);
const scopeRef = useRef(null);
const specRef = useRef(null);
const [cycles, setCycles] = useState(() => {
try {
return Number(localStorage.getItem(CKEY)) || 4;
} catch {
return 4;
}
});
const [show, setShow] = useState({ roll: true, scope: true, spec: true });
const [playing, setPlaying] = useState(false);
// refs para que el bucle lea los valores actuales sin reiniciarse
const cyclesRef = useRef(cycles);
const showRef = useRef(show);
cyclesRef.current = cycles;
showRef.current = show;
useEffect(() => {
try {
localStorage.setItem(CKEY, String(cycles));
} catch {
/* noop */
}
}, [cycles]);
useEffect(() => {
const roll = rollRef.current;
const scope = scopeRef.current;
const spec = specRef.current;
const rctx = roll?.getContext('2d');
const sctx = scope?.getContext('2d');
const fctx = spec?.getContext('2d');
let raf;
let lastNode = null;
// ajusta el tamaño interno del canvas al que ocupa en pantalla
const fit = (cv) => {
if (!cv) return;
const w = cv.clientWidth;
const h = cv.clientHeight;
if (w && h && (cv.width !== w || cv.height !== h)) {
cv.width = w;
cv.height = h;
}
};
// pincha un analizador al bus maestro para poder leer onda/espectro de
// TODO lo que suena (sin que el usuario escriba .scope()). Se reengancha
// solo si el nodo cambia (p.ej. tras reiniciar el motor de audio).
const ensureAnalyser = () => {
try {
getAudioContext(); // fuerza el contexto si aún no existe
const node = getAnalyserById(SCOPE_ID, 1024);
if (node !== lastNode) {
getSuperdoughAudioController().output.destinationGain.connect(node);
lastNode = node;
}
} catch {
/* el audio aún no está listo: se reintenta el siguiente frame */
}
};
const frame = () => {
const scheduler = context.editorRef?.current?.repl?.scheduler;
const started = !!scheduler?.started;
setPlaying((p) => (p !== started ? started : p));
const S = showRef.current;
fit(roll);
fit(scope);
fit(spec);
rctx && rctx.clearRect(0, 0, roll.width, roll.height);
sctx && sctx.clearRect(0, 0, scope.width, scope.height);
fctx && fctx.clearRect(0, 0, spec.width, spec.height);
if (started && scheduler.pattern) {
const time = scheduler.now();
const cyc = cyclesRef.current;
// PENTAGRAMA (piano-roll) de lo que suena en esta ventana de tiempo
if (S.roll && rctx) {
const playhead = 0.5;
const from = -cyc * playhead;
const to = cyc * (1 - playhead);
let haps = [];
try {
haps = scheduler.pattern.queryArc(time + from, time + to).filter((h) => h.hasOnset());
} catch {
haps = [];
}
try {
__pianoroll({
ctx: rctx,
haps,
time,
cycles: cyc,
playhead,
fold: 1,
labels: 1,
background: 'transparent',
playheadColor: '#ff7a18',
});
} catch {
/* noop */
}
}
// ONDA + ESPECTRO del bus maestro
if ((S.scope || S.spec) && (sctx || fctx)) {
ensureAnalyser();
if (S.scope && sctx && analysers[SCOPE_ID]) {
try {
drawTimeScope(analysers[SCOPE_ID], { ctx: sctx, id: SCOPE_ID, color: '#00e5ff', thickness: 2 });
} catch {
/* noop */
}
}
if (S.spec && fctx && analysers[SCOPE_ID]) {
try {
drawFrequencyScope(analysers[SCOPE_ID], { ctx: fctx, id: SCOPE_ID, color: '#ff7a18' });
} catch {
/* noop */
}
}
}
}
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, [context]);
const toggle = (k) => setShow((s) => ({ ...s, [k]: !s[k] }));
const chip = (on) =>
cx(
'px-2 py-0.5 rounded text-xs font-bold border cursor-pointer transition-colors select-none',
on ? 'border-cyan-400 text-cyan-300 bg-cyan-400/10' : 'border-muted opacity-50 hover:opacity-80',
);
return (
<div className="w-full h-full text-foreground flex flex-col overflow-hidden" style={{ fontFamily }}>
{/* controles */}
<div className="flex items-center gap-3 px-2 py-2 border-b border-muted shrink-0 flex-wrap">
<span className="text-xs opacity-70">zoom</span>
<input
type="range"
min="1"
max="16"
step="1"
value={cycles}
onChange={(e) => setCycles(Number(e.target.value))}
className="accent-cyan-400"
title="compases visibles en el pentagrama"
/>
<span className="text-xs tabular-nums w-14">{cycles} comp.</span>
<span className="ml-auto flex gap-1.5">
<span className={chip(show.roll)} onClick={() => toggle('roll')}>
pentagrama
</span>
<span className={chip(show.scope)} onClick={() => toggle('scope')}>
onda
</span>
<span className={chip(show.spec)} onClick={() => toggle('spec')}>
espectro
</span>
</span>
</div>
{/* lienzos */}
<div className="relative grow min-h-0 flex flex-col">
{!playing && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<span className="text-sm opacity-60">dale al play para ver el pentagrama y las ondas</span>
</div>
)}
{show.roll && (
<div className="relative grow min-h-0 border-b border-muted">
<span className="absolute top-1 left-2 text-[10px] uppercase tracking-widest opacity-40 z-10">
pentagrama
</span>
<canvas ref={rollRef} className="w-full h-full block" />
</div>
)}
{show.scope && (
<div className="relative shrink-0 border-b border-muted" style={{ height: 120 }}>
<span className="absolute top-1 left-2 text-[10px] uppercase tracking-widest opacity-40 z-10">onda</span>
<canvas ref={scopeRef} className="w-full h-full block" />
</div>
)}
{show.spec && (
<div className="relative shrink-0" style={{ height: 110 }}>
<span className="absolute top-1 left-2 text-[10px] uppercase tracking-widest opacity-40 z-10">
espectro
</span>
<canvas ref={specRef} className="w-full h-full block" />
</div>
)}
</div>
</div>
);
}