From 7ace286d8afa0359ebecd335cab3f1dc747ee13b Mon Sep 17 00:00:00 2001 From: hacklab Date: Sun, 26 Jul 2026 18:31:37 +0200 Subject: [PATCH] =?UTF-8?q?Integra=20KISUM=20(audio=E2=86=92Strudel)=20+?= =?UTF-8?q?=20pesta=C3=B1a=20Visor=20+=20accesos=20en=20la=20cabecera?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .gitignore | 6 + README.md | 63 ++++- install.sh | 51 ++++ kisum/README.md | 63 +++++ kisum/install.sh | 27 +++ kisum/kisum | 27 +++ kisum/kisum.py | 221 +++++++++++++++++ kisum/kisum_server.py | 120 +++++++++ kisum/requirements.txt | 3 + strudel-launcher.sh | 24 ++ .../src/repl/components/panel/KisumTab.jsx | 196 +++++++++++++++ website/src/repl/components/panel/Panel.jsx | 35 +++ .../src/repl/components/panel/VisorTab.jsx | 229 ++++++++++++++++++ 13 files changed, 1052 insertions(+), 13 deletions(-) create mode 100755 install.sh create mode 100644 kisum/README.md create mode 100755 kisum/install.sh create mode 100755 kisum/kisum create mode 100644 kisum/kisum.py create mode 100644 kisum/kisum_server.py create mode 100644 kisum/requirements.txt create mode 100644 website/src/repl/components/panel/KisumTab.jsx create mode 100644 website/src/repl/components/panel/VisorTab.jsx diff --git a/.gitignore b/.gitignore index 18e1ddc5..96de01d2 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index ec9680d3..053d5172 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..1eac66de --- /dev/null +++ b/install.sh @@ -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)" diff --git a/kisum/README.md b/kisum/README.md new file mode 100644 index 00000000..4ca7f50b --- /dev/null +++ b/kisum/README.md @@ -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 (20–150 Hz → `bd`), medios (150–2500 Hz → `sd`), + agudos (5–11 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 diff --git a/kisum/install.sh b/kisum/install.sh new file mode 100755 index 00000000..e8995a9e --- /dev/null +++ b/kisum/install.sh @@ -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" diff --git a/kisum/kisum b/kisum/kisum new file mode 100755 index 00000000..148d1ef8 --- /dev/null +++ b/kisum/kisum @@ -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" "$@" diff --git a/kisum/kisum.py b/kisum/kisum.py new file mode 100644 index 00000000..2f92ac55 --- /dev/null +++ b/kisum/kisum.py @@ -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() diff --git a/kisum/kisum_server.py b/kisum/kisum_server.py new file mode 100644 index 00000000..1091ea8d --- /dev/null +++ b/kisum/kisum_server.py @@ -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": "", "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() diff --git a/kisum/requirements.txt b/kisum/requirements.txt new file mode 100644 index 00000000..9af7fa1a --- /dev/null +++ b/kisum/requirements.txt @@ -0,0 +1,3 @@ +librosa>=0.10 +soundfile +numpy diff --git a/strudel-launcher.sh b/strudel-launcher.sh index cc34afc2..41254f92 100755 --- a/strudel-launcher.sh +++ b/strudel-launcher.sh @@ -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 & diff --git a/website/src/repl/components/panel/KisumTab.jsx b/website/src/repl/components/panel/KisumTab.jsx new file mode 100644 index 00000000..1e37885f --- /dev/null +++ b/website/src/repl/components/panel/KisumTab.jsx @@ -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 ( +
+ {/* estado del servidor */} +
+ + + {serverUp === true + ? 'servidor KISUM conectado' + : serverUp === false + ? 'servidor KISUM apagado — reabre Strudel con el icono, o arráncalo a mano' + : 'buscando servidor KISUM…'} + +
+ + {/* zona de soltar archivo */} +
+
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 ? ( + + {file.name} + + ) : ( + arrastra aquí un audio o vídeo (mp3, wav, mp4, mkv…) o haz clic + )} + setFile(e.target.files?.[0] || null)} + /> +
+
+ + {/* opciones */} +
+ + + +
+ + {/* resultado */} +
+ {status === 'error' &&

{error}

} + {status === 'loading' &&

escuchando la canción y sacando el ritmo…

} + {status === 'done' && code && ( + <> +
+ + +
+
{code}
+ + )} + {status === 'idle' && ( +

+ 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. +

+ )} +
+
+ ); +} diff --git a/website/src/repl/components/panel/Panel.jsx b/website/src/repl/components/panel/Panel.jsx index 4984c85c..b19f05bf 100644 --- a/website/src/repl/components/panel/Panel.jsx +++ b/website/src/repl/components/panel/Panel.jsx @@ -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 (
{t('learn')} )} + {/* accesos directos a paneles (abren la pestaña correspondiente) */} + {!isEmbedded && } + {!isEmbedded && ( + + )} + {!isEmbedded && ( + + )} + {!isEmbedded && ( + + )} + {!isEmbedded && ( + + )}
); } @@ -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 ; + case tabNames.kisum: + return ; + case tabNames.visor: + return ; case tabNames.guia: return ; case tabNames.patterns: diff --git a/website/src/repl/components/panel/VisorTab.jsx b/website/src/repl/components/panel/VisorTab.jsx new file mode 100644 index 00000000..6ebb0218 --- /dev/null +++ b/website/src/repl/components/panel/VisorTab.jsx @@ -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 ( +
+ {/* controles */} +
+ zoom + setCycles(Number(e.target.value))} + className="accent-cyan-400" + title="compases visibles en el pentagrama" + /> + {cycles} comp. + + toggle('roll')}> + pentagrama + + toggle('scope')}> + onda + + toggle('spec')}> + espectro + + +
+ + {/* lienzos */} +
+ {!playing && ( +
+ dale al play para ver el pentagrama y las ondas +
+ )} + {show.roll && ( +
+ + pentagrama + + +
+ )} + {show.scope && ( +
+ onda + +
+ )} + {show.spec && ( +
+ + espectro + + +
+ )} +
+
+ ); +}