From 6920684d097c1c38a77d208bdb3595ea39210566 Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 30 Jul 2026 03:59:30 +0200 Subject: [PATCH] refactor(sound): extraer el motor Web Audio de sema al art `$sound` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sema/chans/sound.ts` tenía 407 líneas de las que ~255 (≈63%) eran maquinaria Web Audio —ciclo de vida del AudioContext, síntesis, samples, desbloqueo por gesto— mezclada con doctrina perceptiva. Deuda arrastrada, con tres síntomas medidos: el import era estático, así que toda app con sema metía el sintetizador en el bundle aunque el sonido estuviera apagado (que es el default); faltaba ciudadanía que `$scene` ya resuelve; y la costura de inyección (`audioContextFactory`) llevaba ahí sin usar desde el principio. Mismo movimiento que `$motion` hizo desde eidos: el art se lleva el RUNTIME, la capa conserva sus DATOS y su doctrina. - `$sound` / `EngineSound`: UN AudioContext por documento (los navegadores los limitan y el gesto de desbloqueo es por contexto), síntesis de earcon, samples con caché y fallback a síntesis, `autoSuspend` OPT-IN —suspender con la pestaña oculta es correcto para earcons y erróneo para contenido, así que es decisión de quien compone— y aviso cuando un segundo contexto va vivo. Puertos `SoundDom` / `SoundTimers` inyectados; no importa ningún otro art ni nada de `$uix/sema`, que es la prueba objetiva del corte. - `SoundChannel`: 407 → 136 líneas. Solo doctrina: el gate de `prepare`, la política de reducción y el reparto «el canal resuelve el NIVEL, el motor aplica la ganancia». Sema no gana ni un import: recibe el motor por puerto. - `uix.sound` en standalone y attach con `ownsSound` (idioma ya shipped: `ownsMotion` / `ownsScene`), fila `sound` en la tabla ejecutable `contracts.ts`, y `defineEngineSound()` para el camino de app. - Tests nuevos: `engine-sound.test.ts` (11), `sound-port.test.ts` (guard de deriva de tipos + la regla de propiedad) y `sound-e2e.test.ts`, que recorre `emit -> cascada -> canal -> art -> grafo real`: el camino que las 16 suites previas no cubrían porque paraban en canales falsos. Sin `diagnostics.ts` ni `errors.ts`, y es decisión: espejo de `$scene`, aquí todo fallo es degradación documentada, no error de programador. Verificación: 17 suites / 198 tests · `check` en la baseline exacta (73 errores, 0 propios) · `sound.test.ts` verde SIN tocar un solo assert, que era el criterio de que el movimiento fue value-preserving. Planes: `PLAN-sound-engine.md` (completo, con el registro de la revisión adversarial E-1..E-7), `PLAN-audio-player.md` (aparcado tras el análisis del reproductor, con sus correcciones en cabecera) y `CONTINUE-sound-engine.md` (handoff). Co-Authored-By: Claude Opus 5 --- docs/architecture/sema.md | 73 ++- docs/process/CONTINUE-sound-engine.md | 89 ++++ docs/process/PLAN-audio-player.md | 377 +++++++++++++++ docs/process/PLAN-sound-engine.md | 295 ++++++++++++ src/arts/README.md | 9 + .../active-app/service-factories/index.ts | 1 + .../active-app/service-factories/sound.ts | 42 ++ src/arts/sound/README.md | 120 +++++ src/arts/sound/consts.ts | 42 ++ src/arts/sound/engine-sound.test.ts | 260 ++++++++++ src/arts/sound/engine-sound.ts | 443 ++++++++++++++++++ src/arts/sound/index.ts | 12 + src/arts/sound/types.ts | 151 ++++++ src/uix/active-uix/active-uix.svelte.ts | 51 +- src/uix/active-uix/types.ts | 15 + src/uix/contracts.ts | 35 +- src/uix/sema/chans/sound-e2e.test.ts | 132 ++++++ src/uix/sema/chans/sound-port.test.ts | 127 +++++ src/uix/sema/chans/sound.ts | 383 +++------------ src/uix/sema/engine.ts | 26 +- svelte.config.js | 1 + vite.config.ts | 1 + 22 files changed, 2330 insertions(+), 355 deletions(-) create mode 100644 docs/process/CONTINUE-sound-engine.md create mode 100644 docs/process/PLAN-audio-player.md create mode 100644 docs/process/PLAN-sound-engine.md create mode 100644 src/arts/active-app/service-factories/sound.ts create mode 100644 src/arts/sound/README.md create mode 100644 src/arts/sound/consts.ts create mode 100644 src/arts/sound/engine-sound.test.ts create mode 100644 src/arts/sound/engine-sound.ts create mode 100644 src/arts/sound/index.ts create mode 100644 src/arts/sound/types.ts create mode 100644 src/uix/sema/chans/sound-e2e.test.ts create mode 100644 src/uix/sema/chans/sound-port.test.ts diff --git a/docs/architecture/sema.md b/docs/architecture/sema.md index 265e77261..148b9c6c5 100644 --- a/docs/architecture/sema.md +++ b/docs/architecture/sema.md @@ -139,7 +139,8 @@ src/uix/sema/ └── chans/ ├── types.ts Channel interface — handle(signal, effective) ├── visual.ts VisualChannel (data-event projection + hold) - ├── sound.ts SoundChannel (Web Audio earcons + sample playback) + ├── sound.ts SoundChannel (gate + reduction policy; the Web Audio + │ runtime lives in the `$sound` art, injected) └── haptic.ts HapticChannel (Vibration API + categorical kinds) ``` @@ -595,29 +596,55 @@ hand-written examples this section used to show targeted `[data-toast-root]` { selector: semaSelector(dialogMorfo, 'content', { eventNamePrefix: 'close-' }), sound: { contour: 'descending' } } ``` -### SoundChannel - -Short earcons synthesized via the Web Audio API from `effective.sound` -(pitch / centroid / roughness / attack / decay / duration / contour / gain). -Details: - -- A single `AudioContext` with a master `GainNode` per engine. -- **Prepare-time priming**, no constructor side effect. The channel creates + - resumes the `AudioContext` in `prepare()` when the signal admits `sound`, - synchronously inside the user gesture. -- After creating the context, it registers a `click` / `touchstart` / - `keydown` listener via the injected DOM surface - (`ActiveDom.listen(ActiveDom.getDocument(), ...)`) to re-resume after - passive suspends (tab switch, etc.). If the channel never prepares an - audible signal, it installs no global listeners. +### SoundChannel — doctrine here, machine in `$sound` + +> **The Web Audio machinery does NOT live in sema** (since 2026-07-30). The +> context lifecycle, the synthesis graph, the unlock-on-gesture and the sample +> path were extracted to the art [`$sound`](../../src/arts/sound/README.md) +> (`EngineSound`) — they were ~63% of a 407-line "channel" and are machinery, +> not perceptual doctrine. Same move `$motion` made out of eidos, for the same +> reason and with the same result: the art owns the RUNTIME, the layer keeps its +> DATA and doctrine. + +What the channel keeps — all of it doctrine: + +- the `prepare` gate (does this signal admit sound at all?), +- the per-channel reduction policy (BK-REDUCTIONS): `off` silences (meaning + migrates via `SEMA_MIGRATION.sound` → presence / live region), `reduce` + attenuates, +- and the division of labour: **the channel resolves the LEVEL, the engine + applies the gain**. + +Everything else — `SOUND_LIBRARY`, `SOUND_TUNINGS`, the gesture resolvers, the +cascade — is unchanged and still sema's. + +**How it reaches the engine.** `EngineSemantic` takes a `soundEngine` option and +forwards it; `ActiveUix` creates the engine and injects it as `uix.sound`. Sema +**imports no art**: it receives a structural port, the `MotionDom` / `SceneDom` +pattern. Without an injected engine the channel creates a private one and owns +its lifecycle — *whoever creates, disposes*; a shared engine is never closed by +a consumer. + +**Why one engine matters.** Browsers cap concurrent `AudioContext`s and the +autoplay unlock gesture is per-context, so a second context leaves one of the +two mute. An app that also plays content (a media player, a waveform) must take +`uix.sound` — or pass `soundEngine` to its own `EngineSemantic` — instead of +opening its own. The art warns when a second context goes live. + +Behaviour, unchanged by the extraction: + +- **Prepare-time priming**, no constructor side effect: the context is created + + resumed in `prepare()` when the signal admits `sound`, synchronously inside + the user gesture. +- The unlock listener (`pointerdown` / `mousedown` / `click` / `touchstart` / + `keydown`) is registered only AFTER the context exists, through the injected + DOM surface. An engine that never plays installs no global listeners. - Synthesis: two oscillators (sine + a fifth) → biquad lowpass (centroid) → - ADSR-lite envelope. If `roughness > 0.2`, a fast AM modulator. -- `contour` (`flat` / `ascending` / `descending` / `arc` / `bell`) is applied - via `osc.detune`. -- If the signature carries a `sampleUrl`, it plays the sample (with an - `AudioBuffer` cache) instead of synthesizing. -- Any failure (no AudioContext, decode failure) is absorbed — sema is - ornamental. + ADSR-lite envelope; `roughness > 0.2` adds a fast AM modulator; `contour` + (`flat` / `ascending` / `descending` / `arc` / `bell`) rides `osc.detune`. +- A signature carrying `sampleUrl` plays the sample (with an `AudioBuffer` + cache) and **falls back to synthesis** on fetch / decode failure. +- Any failure is absorbed — sema is ornamental. The prepare-time priming pattern applies in general to any channel whose backend has a "first time must happen inside a gesture" restriction: audio, diff --git a/docs/process/CONTINUE-sound-engine.md b/docs/process/CONTINUE-sound-engine.md new file mode 100644 index 000000000..d7b507a0f --- /dev/null +++ b/docs/process/CONTINUE-sound-engine.md @@ -0,0 +1,89 @@ +# CONTINUE — motor de sonido + reproductor + +> **Kickoff para la sesión siguiente**: *"Lee `docs/process/CONTINUE-sound-engine.md` +> y sigue por donde toque."* +> **Fecha**: 2026-07-30 · Rama `alpha-0.1-sec-dom`. + +Handoff corto. La verdad detallada vive en los dos planes; esto solo dice **en +qué punto está cada cosa y qué trampas evitar**. + +--- + +## Estado + +| Iniciativa | Estado | +| --- | --- | +| [`PLAN-sound-engine.md`](./PLAN-sound-engine.md) | ✅ **COMPLETA** — F0…F4 hechas. Solo queda F5 (desbloquear el reproductor), que es decisión de sesión | +| [`PLAN-audio-player.md`](./PLAN-audio-player.md) | ⏸️ **APARCADO** — D-AP.1…D-AP.12 sin firmar. Su cabecera lleva **5 correcciones pendientes de incorporar** antes de presentar nada | + +## Qué se construyó + +**`$sound` / `EngineSound`** ([`src/arts/sound/`](../../src/arts/sound/README.md)) — el +runtime Web Audio extraído de `sema/chans/sound.ts`, que tenía ~63% de máquina +mezclada con doctrina. Un `AudioContext` por documento, síntesis de earcon, +samples con caché y fallback, desbloqueo por gesto, `autoSuspend` opt-in y aviso +al segundo contexto vivo. + +- `SoundChannel`: **407 → 136 líneas**, solo doctrina (gate de `prepare`, + política de reducción, «el canal resuelve el NIVEL, el motor aplica la + ganancia»). +- Sema **no importa el art**: lo recibe por puerto inyectado. +- `uix.sound` en ambos modos de arranque, con `ownsSound` (idioma ya shipped: + `ownsMotion` / `ownsScene`), fila en la tabla ejecutable `contracts.ts`, y + `defineEngineSound()` para attach. + +**Gate al cerrar**: 17 suites / 198 tests · `check` en la baseline exacta +(73 errores, 0 propios) · `sound.test.ts` verde **sin tocar un solo assert**. + +## Lo que NO está verificado (y hay que verificar) + +1. **La invariante de contexto único en un navegador real.** Hoy **ninguna** + página enchufa `soundEngine` a su propio `EngineSemantic` — el estudio de + sema incluido ([`temas/sema/_lib/audition.ts:35`](../../web/routes/temas/sema/_lib/audition.ts) + monta el suyo con `sound: true`), así que esa página abre un **segundo + contexto**. Con el aviso nuevo ya lo dirá por consola: **abrir `/temas/sema` + y mirar el warn es la primera comprobación de mañana.** Arreglo natural: + pasarle `soundEngine: uix.sound`. +2. **La escucha comparativa fina.** El usuario confirmó que suena, pero no se + hizo un A/B earcon a earcon contra el comportamiento previo. + +## Trampas de este repo (verificadas a base de perder tiempo) + +- **NO arrancar un dev server si el del usuario está vivo.** `npm run dev` es + `vite dev --force`: reescribe `node_modules/.vite/deps` por debajo del que + corre → dos runtimes de Svelte → la página deja de hidratar + (`lifecycle_outside_component`) → parece regresión de audio y no lo es. + Pasó el 2026-07-30. Verificar con **tests deterministas** (dobles de + `AudioContext`), no levantando servidores. + Recuperación (PowerShell): parar node → + `Remove-Item -Recurse -Force node_modules/.vite/deps` → `npm run dev` → + **Ctrl+Shift+R**. +- **El panel del navegador no pinta** en este entorno (viewport 0×0); + `javascript_tool` sí funciona. +- **Filtrar `npm run check` con cuidado**: las rutas llevan `\\` doble, así que + un patrón `sema.chans` NO casa con `sema\\chans` y da falsos «0 errores + propios». Ocurrió. +- **El árbol está compartido con otras sesiones** (palabras, menubar, + dropdown-menu, blocks/cta, alpha…). `git reset -q` + `add` solo lo propio, + siempre en un bloque atómico. + +## Ajenos y preexistentes detectados (NO tocados) + +- `contracts.test.ts` 3 rojos: `aura`, `menubar`, `radio-group`. +- `docs:check` 1 error: `eidos/components/callout/README.md:23` dice «8 roles», + son 9. +- `Board.svelte:42` warn `binding_property_non_reactive` — inofensivo ahí + (lectura imperativa); se arregla con `let els = $state({})`. +- `temas/sema/_lib/audition.ts` tiene 2 errores de tipos preexistentes. + +## Siguientes pasos posibles + +1. **F5** — desbloquear el reproductor y reescribir su **D-AP.11** contra el art + ya existente (su premisa —«no hay motor»— era falsa). +2. **Cerrar la invariante**: pasar `soundEngine` al `EngineSemantic` del estudio + y comprobar que el warn desaparece. +3. **Retomar el reproductor** por su F0: firmar D-AP.\* con las 5 correcciones + de la cabecera ya incorporadas (segmentos, G-2 bloqueante, ducking…). + +**No registrado en `next-features.md` a propósito**: ese fichero está modificado +por otra sesión y no se toca desde aquí. diff --git a/docs/process/PLAN-audio-player.md b/docs/process/PLAN-audio-player.md new file mode 100644 index 000000000..85bf3425b --- /dev/null +++ b/docs/process/PLAN-audio-player.md @@ -0,0 +1,377 @@ +# PLAN — Reproductor de sonido (`AudioPlayer`) + +> **Tipo**: plan de creación por fases (process — efímero, NO fuente de verdad). +> **Fecha**: 2026-07-30 · **Estado**: **⏸️ APARCADO 2026-07-30 — bloqueado por +> [`PLAN-sound-engine.md`](./PLAN-sound-engine.md)**. Decisiones D-AP.1…D-AP.10 +> SIN FIRMAR. **No arrancar F0 hasta que el art `$sound` exista** (su F5 desbloquea +> éste). +> **Kickoff para sesión nueva**: *"Lee `docs/process/PLAN-sound-engine.md` primero; +> este plan está aparcado detrás de él."* +> +> ### ⚠️ Correcciones pendientes de incorporar (sesión 2026-07-30, posteriores al cuerpo) +> +> El cuerpo de abajo NO las refleja todavía. Al desaparcar, incorporarlas ANTES +> de presentar nada: +> +> 1. **Faltaba la reproducción de SEGMENTOS entera.** No está en la matriz, ni en +> el contrato, ni en las variantes. Y **Vidstack SÍ los tiene** +> (`clipStartTime` / `clipEndTime`, con los que calcula el rango *seekable*), +> así que es **paridad pendiente, no superación**. Diseño acordado: `clip = +> { start, end, loop? }` en el provider · capa de proyección absoluto↔relativo +> (dominio del slider `0…end−start`, `timeBase: 'clip' | 'source'` en `Time`, +> misma base en `aria-valuetext`) · clamp en el provider, no en el puerto · +> `commit-complete` dispara en el fin del clip (mismo evento, frontera movida) +> · **con `loop` NO hay `commit-complete`** (un bucle esperado no es ocurrencia +> — regla B.5) · limitación declarada: `timeupdate` ~4/s ⇒ punto de bucle +> impreciso sin Web Audio · riesgo a probar: bug abierto de clip en Chrome/ +> Safari móvil (vidstack#1195). `chapters` = v2 · `regions` = descartado. +> 2. **G-2 (pista secundaria del `Slider`) sube a BLOQUEANTE de v1**: ya tiene +> tres consumidores — buffer + ventana de clip + capítulos. +> 3. **D-AP.11 queda obsoleta y se reescribe contra el art.** Su premisa («no hay +> motor, ¿dónde lo ponemos?») era falsa: el ecosistema ya tenía un motor Web +> Audio de 407 líneas dentro de `sema/chans/sound.ts`. Cuando este plan se +> desaparque, `$sound` ya existirá y el player solo declara si lo consume. +> 4. **Dos invariantes a escribir en el contrato**: (a) **`prefs.sound` nunca toca +> el volumen del contenido** — gobierna el sonido de UI (sema); un reproductor +> de sonido es el primer componente del catálogo donde «sonido» significa dos +> cosas. (b) El silencio de UI en audio (D-AP.7) debe alcanzar al **`Slider` +> compuesto**: existe `resolveSliderDragSound` y arrastrar el scrubber +> sonorizaría por encima del contenido — verificar con stamp real y, si +> dispara, la regla es `[data-media-player][data-media='audio'] [data-slider]`. +> 5. **D-AP.7 se reescribe como *ducking***: con un motor dueño del earcon y del +> contenido, deja de ser regla de cascada y pasa a capacidad del motor. +> +> **Antes de escribir una línea de código**: presentar al usuario las decisiones +> de §4 y obtener firma. Lo de aquí son **propuestas razonadas, no decisiones +> tomadas** — regla A32 (`component-guide.md`) + §0.5 de +> [`component-audit.md`](../guides/component-audit.md) ("Get the user's sign-off +> on scope before writing any code"). +> +> **Precondición cumplida**: corpus doctrinal leído completo antes de auditar +> (regla dura `feedback_read_full_doctrine_before_auditing`): README · overview · +> active-architecture · CANON · morfo · sema · soma(+architecture) · eidos · +> active-uix · active-app · packs · blocks · agent · TSC · recipe-contract · +> vocabularies · component-guide · completion-checklist · component-audit · +> demo-authoring · theming(reference/guide/notes/motion/motion-guide/channels/ +> gradient-finish) · los 7 RFCs · book-deviations · design-text-effects · +> design-chat-block · glossary · comparison · authoring · testing-and-tooling · +> next-features · decisions · book-map · guía histórica · veredictos/_system/ +> _naming/_cierre de la auditoría · `eidos/components/README.md` · `arts/README.md`. + +--- + +## 0. Contexto — qué existe hoy y qué NO + +El ecosistema **ya tiene un reproductor**: `media-player`, genérico +`