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>
This commit is contained in:
parent
970b89b08c
commit
7ace286d8a
13 changed files with 1052 additions and 13 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
63
README.md
63
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
|
||||
|
|
|
|||
51
install.sh
Executable file
51
install.sh
Executable 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
63
kisum/README.md
Normal 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 (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
|
||||
27
kisum/install.sh
Executable file
27
kisum/install.sh
Executable 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
27
kisum/kisum
Executable 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
221
kisum/kisum.py
Normal 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
120
kisum/kisum_server.py
Normal 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
3
kisum/requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
librosa>=0.10
|
||||
soundfile
|
||||
numpy
|
||||
|
|
@ -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 &
|
||||
|
|
|
|||
196
website/src/repl/components/panel/KisumTab.jsx
Normal file
196
website/src/repl/components/panel/KisumTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
229
website/src/repl/components/panel/VisorTab.jsx
Normal file
229
website/src/repl/components/panel/VisorTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue