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