19 KiB
PLAN — Waveform (visualización interactiva de audio)
Tipo: plan de creación por fases (process — efímero, NO fuente de verdad). Fecha: 2026-07-31 · Estado: ✅ INICIATIVA COMPLETA 2026-08-01 — gate firmado el 2026-07-31 («ok» tras la presentación única: las 8 con las recomendaciones) y F1–F6 cerradas, F6 con la mirada del usuario. Origen: iniciativa hermana FIRMADA en D-AP2.4 (
PLAN-audio-player-v2.md): componente canónico propio, POST-v1 del player — «tiene superficie de contrato real… y sirve fuera del player». Los cimientos que este plan asume ya existen y están commiteados:uix.sound.decode(bytes)(la puerta de muestras),Sliderconaria-valuetext(G-1) ySecondaryRange(G-2), y$libs/plots(scaleLinear+area/line, verificado en el índice). Kickoff sesión nueva: "Leedocs/process/PLAN-waveform.md; si el gate está firmado, sigue por la fase abierta (releyendocomponent-guide.mdal abrir F2)."
0. Qué es y quién lo consume
La representación de una señal de audio como onda/barras, interactiva (el sector la usa como scrubber: SoundCloud, wavesurfer) o pasiva. Consumidores reales que la barra ≥2 exige:
| Consumidor | Qué necesita |
|---|---|
media-player (TimeSlider-onda, v2 del player — FUERA de este plan) |
onda + seek + progreso |
chat-message (nota de voz) |
onda compacta + seek |
| Estudio sema / editor | onda pasiva de un buffer decodificado |
1. Referencias (F-1.2, ≥3)
| Ref | Modelo |
|---|---|
| wavesurfer.js 7 | el canon del sector: peaks precomputados O decode cliente; render canvas; regions/plugins |
| peaks.js (BBC) | separación DATOS/render: audiowaveform (server, C++) genera los picos; el cliente solo pinta |
| SoundCloud | la UX de referencia: barras + dos colores (pasado/futuro) + seek |
| WhatsApp voice note | la forma compacta: barras + cursor, en una fila de chat |
Lectura: la ruta profesional NO decodifica en el cliente (coste + CORS);
los picos son un dato que la app provee. El decode cliente es la conveniencia
opcional para ficheros locales/pequeños — y nuestra puerta ya existe
(uix.sound.decode).
2. La observación que encoge el plan
Un waveform interactivo ES un slider con otra piel. El drag continuo, el
click-to-jump, el teclado (← → Home End PageUp/Down), aria-valuemin/max/now,
aria-valuetext (G-1) y el commit-set del release — TODO eso ya vive en el
Slider canónico y sus señales sema. Re-declararlo sería exactamente la
duplicación que D-AP2.13 acaba de retirar del player.
Propuesta: Waveform COMPONE Slider (el patrón del TimeSlider del
player), y su morfo declara solo la capa que el Slider no tiene: la onda.
Consecuencia: cero eventos propios en v1 (las señales son las del Slider
embebido) y el foco del componente queda en su verdadero contrato — los datos
(peaks) y su render.
3. Decisiones (gate del usuario — SIN FIRMAR)
| ID | Cuestión | Recomendación |
|---|---|---|
| D-WF.1 | Membresía / composición | Componer Slider (§2): morfo propio con partes de onda + Slider embebido para toda la interacción. La alternativa (re-implementar drag/teclado/aria) duplica un contrato PASS sin ganar nada |
| D-WF.2 | Los datos | peaks: number[] (0..1) provistos por la app (firmado ya en D-AP2.4; la ruta BBC/SoundCloud). Sin fetch ni decode implícitos en el componente |
| D-WF.3 | Extractor de picos cliente (opcional) | sound.peaks(buffer, buckets) en el art $sound — es la disposición escrita del plan del motor («percepción: analyser/picos, v2») y el flujo natural es decode → peaks en la misma puerta. Alternativas: $libs (puro, pero AudioBuffer es plataforma) o el componente (privilegio local, el antipatrón). Superficie: ~20 líneas (min/max por bucket sobre getChannelData) |
| D-WF.4 | Render | SVG vía $libs/plots (firmado en D-AP2.4): scaleLinear + generadores de path. Sin canvas (un canvas suelto exigiría la ciudadanía de $scene; para N picos estáticos el SVG sobra y hereda theming/tokens gratis) |
| D-WF.5 | Formas del render | shape: 'bars' | 'wave' — barras (SoundCloud, default) y área continua espejada (wavesurfer). Ambas son un path distinto sobre los mismos picos; coste marginal |
| D-WF.6 | Progreso (dos colores) | clipPath por fracción de progreso sobre un segundo path coloreado — cero re-render por frame (el clip se mueve con una CSS var/attr del provider, como el rAF del player ya alimenta el value del Slider) |
| D-WF.7 | Interactividad | Interactivo por defecto (seek); readonly lo vuelve pasivo (el Slider ya tiene la prop — se reenvía). Sin disabled propio: el del Slider |
| D-WF.8 | Sema | Sin pack propio en v1: cero eventos propios (D-WF.1) → las firmas son las del Slider embebido. Dentro del player, el silencio por descendencia YA lo cubre (D-AP2.7 v2). expression del morfo acorde (sin pack, guard S11d en paz) |
4. Contrato propuesto (borrador para F2 — se cierra con la firma)
- Partes:
Provider(group; dueño de props/geometría) ·Wave(el<svg>;data-shape) ·Sliderembebido (montado por el wrapper soma, como TimeSlider) ·Cursor(indicator, opcional — la posición actual cuando se quiere además del clip de color). - Props soma:
peaks: number[]·value/onValueChange/onValueCommit·min/max(dominio real: segundos) ·shape·readonly·valueText(reenviado a G-1) ·secondaryValue(reenviado a G-2: buffer/preload sobre la onda). - Eidos: tokens
--waveform-*(color pasado/futuro/cursor, bar-width/gap, radius); tamaño pordata-sizecomo el slider. - A11y: la del Slider embebido (aria completo + valuetext); el
Waveesaria-hidden(presentacional — el valor accesible es el slider).
5. Fases (tras la firma; método no-cascada; releer component-guide.md al abrir F2)
| Fase | Contenido | Verify |
|---|---|---|
F1 — sound.peaks ✅ 2026-07-31 |
Extractor max-abs por bucket sobre TODOS los canales, normalizado por el bucket más alto (documentado: una grabación floja pinta onda completa; silencio total queda plano); puro, sin contexto ni guard disposed (nada que fugar) | ✅ tests (mono · multicanal con valores float32-exactos · silencio · degenerados) — 42/42 con el port |
| F2 — Morfo ✅ 2026-07-31 | 2 partes (provider con data-shape/data-readonly · wave svg presentacional aria-hidden) · SIN campo events · expression: 'delegated' (la firma perceptiva es del Slider embebido) · el part Cursor del borrador PODADO como especulativo (el thumb del Slider ES el playhead) |
✅ 4/4 (validate lanza-o-no, no lista · pin de cero eventos vía 'events' in) · vocabulario limpio |
| F3 — Soma ✅ 2026-07-31 | WaveformProvider (geometría TESTEABLE en la clase: buildWavePath — bars = trazos verticales con FLOOR perceptivo 0.5 y ancho vía stroke-width de la recipe; wave = area() de plots espejada — · progressFraction clampada · label localizado para el Slider) · WaveformWaveProvider (renderProps) · wrapper componiendo Slider completo (SecondaryRange+Range+Thumb; reenvíos de readonly/disabled/step/valueText/secondaryValue) · Wave svelte con el clip de progreso (2 copias del MISMO path; data-waveform-played/-remaining hooks eidos-only) |
✅ 4/4 (contrato+geometría exacta M25.00 15.5V16.5M75.00 1V31 · área cerrada+clamp · Wave presentacional · degradación sin picos) — lección re-aplicada: data reactivos del morfo se miden EN EL ELEMENTO tras tick (syncAttrs) |
| F4 — Eidos ✅ 2026-08-01 | Wrapper raíz (carga slider.css+waveform.css; data-size resuelto por ActiveEidos) · Wave passthrough · recipe: bars = stroke en UNIDADES DE VIEWBOX (el svg estira con preserveAspectRatio="none" → ciclo barra/hueco constante) / wave = fill sin stroke · RTL espejo CSS ([dir='rtl'] → scaleX(-1), mismo borde que el thumb) · readonly cursor · disabled vía :has (la onda vive FUERA del slider) · Slider en scope: track/range transparentes, secondary = velo 14%, thumb = playhead 2px×100% (scale-active 1: una línea a alto completo no puede crecer sin desbordar), hit = caja completa (specificidad 3-attr, independiente del orden de carga) · tokens waveform:{height-xs…xl, bar-width, color, color-played, playhead-width} + generate — playhead-width AÑADIDO sobre la lista firmada: el thumb cuadrado (--slider-thumb-size-*) no expresa una línea · guards: fila en component-visual-attrs + sufijos played/remaining en la allowlist de eidos-lint-all |
✅ recipe-css-contract PASS · eidos-lint 0 inválidos (4 morfo-backed) · component-api-contract PASS · check 0 propios |
| F5 — Demo + dossier ✅ 2026-08-01 | Demo con picos REALES (fetch /sounds/*.wav → uix.sound.decode → uix.sound.peaks; 5 señales conmutables + buckets vivo + degradación declarada si no hay contexto) · cada prop = control vivo (shape/value/step/secondaryValue/readonly/disabled/dir/size) · trace sema del Slider embebido · 6 tabs · READMEs soma+eidos (comparativa wavesurfer/peaks.js/SoundCloud/WhatsApp; ## Passive justification = delegación D-WF.1/D-WF.8) · fix F3 declarado: dir NO se reenviaba al Slider embebido — completado el patrón de reenvíos (types+wrapper soma; verificado en vivo: thumb ancla right:0% en RTL) |
✅ component:audit --only waveform PASS · docs:check 0 · check 0 propios · sonda en Chrome vivo (5173): picos reales pintados, clip↔thumb lockstep exacto, shape/size/RTL/readonly vivos |
| F6 — Mirada ✅ 2026-08-01 | FIRMADA por el usuario («podemos darlo por bueno el waveform»), contrastando el render contra el reproductor de Suno AI — que valida dos decisiones del gate: barras (D-WF.5, lectura SoundCloud) y los dos colores reproducido/pendiente como lo esencial (Suno ni siquiera pinta buffered; en el nuestro es opcional y arranca apagado). La mirada costó DOS correcciones que ninguna sonda había visto: (1) el anillo del commit enmarcando el control entero — firma global de la familia, exenta ahora en slider.css/media-player.css (commit df86a4e50); (2) el buffered, rechazado dos veces — primero por color (velo neutro = mismo token que la onda) y luego por FORMA (copia del path = ilegible por construcción, «confunde visualmente»): acabó en banda recta con acento lavado (905012507). Ojo para el futuro: con el panel oculto las CSSTransitions quedan running congeladas y getComputedStyle da valores INTERMEDIOS — medir la cascada (custom props), no los usados |
✅ mirada del usuario |
6. Fuera de alcance (con disposición)
Regions/selección (el modelo wavesurfer-plugin — el clip v2 del player tiene su
propio diseño congelado) · zoom · render en vivo del micrófono (eso es
analyser + $scene, la otra disposición del motor) · minimap.