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

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>
);
}