Visor: onda y espectro mucho más útiles
Some checks are pending
Build and Deploy to warm (strudel.cc) / build (push) Waiting to run
Strudel tests / build (20) (push) Waiting to run

- Onda: osciloscopio estable con disparo por cruce por cero + auto-ganancia
  suavizada (se ve bien con el tema flojo o fuerte), con brillo neón.
- Espectro: bandas logarítmicas (graves→agudos como los oye el oído), barras
  con gradiente por frecuencia y peak-hold (picos que caen despacio).
- Dos analizadores (tiempo/frecuencia) sobre el bus maestro; canvas de
  onda/espectro con devicePixelRatio para verse nítidos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hacklab 2026-07-26 18:43:37 +02:00
parent 7ace286d8a
commit 494da32169

View file

@ -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 (gravesagudos) 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 (
<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
@ -194,7 +297,6 @@ export function VisorTab({ context }) {
</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">
@ -216,7 +318,7 @@ export function VisorTab({ context }) {
</div>
)}
{show.spec && (
<div className="relative shrink-0" style={{ height: 110 }}>
<div className="relative shrink-0" style={{ height: 120 }}>
<span className="absolute top-1 left-2 text-[10px] uppercase tracking-widest opacity-40 z-10">
espectro
</span>