|
|
# 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),
|
|
|
> `Slider` con `aria-valuetext` (G-1) y `SecondaryRange` (G-2), y `$libs/plots`
|
|
|
> (`scaleLinear` + `area`/`line`, verificado en el índice).
|
|
|
> **Kickoff sesión nueva**: _"Lee `docs/process/PLAN-waveform.md`; si el gate
|
|
|
> está firmado, sigue por la fase abierta (releyendo `component-guide.md` al
|
|
|
> 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`) · `Slider` embebido (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 por `data-size` como el slider.
|
|
|
- **A11y**: la del Slider embebido (aria completo + valuetext); el `Wave` es
|
|
|
`aria-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.
|