diff --git a/website/src/repl/components/panel/VisorTab.jsx b/website/src/repl/components/panel/VisorTab.jsx index 6ebb0218..3d3621c1 100644 --- a/website/src/repl/components/panel/VisorTab.jsx +++ b/website/src/repl/components/panel/VisorTab.jsx @@ -3,23 +3,122 @@ import cx from '@src/cx.mjs'; import { useSettings } from '@src/settings.mjs'; import { __pianoroll } from '@strudel/draw'; import { - drawTimeScope, - drawFrequencyScope, getAudioContext, getAnalyserById, + getAnalyzerData, 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 +// Visor: dibuja EN VIVO lo que suena, sin escribir nada en el código. +// - PENTAGRAMA (piano-roll): notas de lo que se está tocando ahora. +// - ONDA: osciloscopio estable (disparo por cruce por cero + auto-ganancia). +// - ESPECTRO: bandas logarítmicas (graves→agudos) con peak-hold. +// Para que funcione con CUALQUIER código (sin .scope()) pinchamos dos // AnalyserNode al bus maestro de superdough (destinationGain). const CKEY = 'strudel-visor-cycles'; // zoom del pentagrama (compases visibles) -const SCOPE_ID = 'visor'; +const ID_T = 'visorTime'; // analizador de forma de onda +const ID_F = 'visorFreq'; // analizador de espectro +const FFT = 2048; + +const CYAN = '#00e5ff'; +const ORANGE = '#ff7a18'; + +// ── osciloscopio estable ──────────────────────────────────────────── +function drawOnda(ctx, data, gainRef) { + const w = ctx.canvas.width; + const h = ctx.canvas.height; + const n = data.length; + const mid = h / 2; + + // línea central tenue + ctx.strokeStyle = 'rgba(255,255,255,0.12)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, mid); + ctx.lineTo(w, mid); + ctx.stroke(); + + // disparo: primer cruce por cero ascendente (estabiliza la imagen) + let start = 0; + const search = Math.floor(n / 2); + for (let i = 1; i < search; i++) { + if (data[i - 1] <= 0 && data[i] > 0) { + start = i; + break; + } + } + + // auto-ganancia suave: normaliza al pico reciente para que se vea igual de + // bien con el tema flojo o fuerte (con límite para no ampliar el silencio). + let peak = 0; + for (let i = 0; i < n; i++) { + const a = Math.abs(data[i]); + if (a > peak) peak = a; + } + const amp = mid * 0.92; // altura útil (deja margen arriba/abajo) + const norm = 1 / Math.max(peak, 0.02); // multiplicador para que el pico llene amp + gainRef.g += (norm - gainRef.g) * 0.15; // suavizado entre frames + const k = amp * Math.min(gainRef.g, 6); // tope de amplificación x6 + + const len = n - start; + ctx.lineWidth = 2; + ctx.strokeStyle = CYAN; + ctx.shadowBlur = 8; + ctx.shadowColor = CYAN; + ctx.beginPath(); + for (let i = 0; i < len; i++) { + const x = (i / (len - 1)) * w; + let s = data[start + i] * k; + if (s > amp) s = amp; + else if (s < -amp) s = -amp; + const y = mid - s; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + ctx.shadowBlur = 0; +} + +// ── espectro con bandas logarítmicas + peak-hold ──────────────────── +function drawEspectro(ctx, data, sampleRate, peaks) { + const w = ctx.canvas.width; + const h = ctx.canvas.height; + const bins = data.length; // dB, lineal de 0..nyquist + const nyquist = sampleRate / 2; + const BANDS = peaks.length; + const fmin = 30; + const fmax = Math.min(16000, nyquist); + const minDb = -90; + const maxDb = -10; + const bw = w / BANDS; + + for (let b = 0; b < BANDS; b++) { + const f0 = fmin * Math.pow(fmax / fmin, b / BANDS); + const f1 = fmin * Math.pow(fmax / fmin, (b + 1) / BANDS); + let i0 = Math.floor((f0 / nyquist) * bins); + let i1 = Math.floor((f1 / nyquist) * bins); + if (i1 <= i0) i1 = i0 + 1; + let m = -Infinity; + for (let i = i0; i < i1 && i < bins; i++) if (data[i] > m) m = data[i]; + if (m === -Infinity) m = minDb; + let v = (m - minDb) / (maxDb - minDb); + v = v < 0 ? 0 : v > 1 ? 1 : v; + + const x = b * bw; + const bh = v * h; + // color por frecuencia: graves naranjas → agudos cian (tema neón) + const hue = 25 + (b / BANDS) * 165; + ctx.fillStyle = `hsl(${hue}, 90%, 55%)`; + ctx.fillRect(x, h - bh, Math.max(1, bw * 0.8), bh); + + // peak-hold: cae despacio + peaks[b] = Math.max(v, peaks[b] - 0.015); + const py = h - peaks[b] * h; + ctx.fillStyle = 'rgba(255,255,255,0.85)'; + ctx.fillRect(x, py - 1.5, Math.max(1, bw * 0.8), 1.5); + } +} export function VisorTab({ context }) { const { fontFamily } = useSettings(); @@ -37,7 +136,6 @@ export function VisorTab({ context }) { 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; @@ -59,32 +157,39 @@ export function VisorTab({ context }) { const sctx = scope?.getContext('2d'); const fctx = spec?.getContext('2d'); let raf; - let lastNode = null; + let lastT = null; + let lastF = null; + const gainRef = { g: 1 }; + const peaks = new Float32Array(64); // bandas del espectro - // ajusta el tamaño interno del canvas al que ocupa en pantalla - const fit = (cv) => { + // el piano-roll usa tamaños de texto en píxeles → dpr 1; onda/espectro + // los dibujo yo, así que ahí sí uso dpr para que se vean nítidos. + const dpr = Math.min(2, window.devicePixelRatio || 1); + const fit = (cv, scale) => { if (!cv) return; - const w = cv.clientWidth; - const h = cv.clientHeight; + const w = Math.round(cv.clientWidth * scale); + const h = Math.round(cv.clientHeight * scale); 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 = () => { + const ensureAnalysers = () => { 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; + getAudioContext(); + const nt = getAnalyserById(ID_T, FFT, 0); + if (nt !== lastT) { + getSuperdoughAudioController().output.destinationGain.connect(nt); + lastT = nt; + } + const nf = getAnalyserById(ID_F, FFT, 0.7); + if (nf !== lastF) { + getSuperdoughAudioController().output.destinationGain.connect(nf); + lastF = nf; } } catch { - /* el audio aún no está listo: se reintenta el siguiente frame */ + /* audio aún no listo */ } }; @@ -94,25 +199,23 @@ export function VisorTab({ context }) { setPlaying((p) => (p !== started ? started : p)); const S = showRef.current; - fit(roll); - fit(scope); - fit(spec); + fit(roll, 1); + fit(scope, dpr); + fit(spec, dpr); 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 + // PENTAGRAMA if (S.roll && rctx) { + const cyc = cyclesRef.current; 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()); + haps = scheduler.pattern.queryArc(scheduler.now() + from, scheduler.now() + to).filter((h) => h.hasOnset()); } catch { haps = []; } @@ -120,32 +223,33 @@ export function VisorTab({ context }) { __pianoroll({ ctx: rctx, haps, - time, + time: scheduler.now(), cycles: cyc, playhead, fold: 1, labels: 1, background: 'transparent', - playheadColor: '#ff7a18', + playheadColor: ORANGE, }); } catch { /* noop */ } } - // ONDA + ESPECTRO del bus maestro - if ((S.scope || S.spec) && (sctx || fctx)) { - ensureAnalyser(); - if (S.scope && sctx && analysers[SCOPE_ID]) { + // ONDA + ESPECTRO + if (S.scope || S.spec) { + ensureAnalysers(); + if (S.scope && sctx) { try { - drawTimeScope(analysers[SCOPE_ID], { ctx: sctx, id: SCOPE_ID, color: '#00e5ff', thickness: 2 }); + drawOnda(sctx, getAnalyzerData('time', ID_T), gainRef); } catch { /* noop */ } } - if (S.spec && fctx && analysers[SCOPE_ID]) { + if (S.spec && fctx) { try { - drawFrequencyScope(analysers[SCOPE_ID], { ctx: fctx, id: SCOPE_ID, color: '#ff7a18' }); + const sr = getAudioContext().sampleRate || 44100; + drawEspectro(fctx, getAnalyzerData('frequency', ID_F), sr, peaks); } catch { /* noop */ } @@ -167,7 +271,6 @@ export function VisorTab({ context }) { return (