# PLAN — `$sound` / `EngineSound`: extraer el motor de audio a un art > **Tipo**: plan de ejecución por fases (process — efímero, NO fuente de verdad). > **Fecha**: 2026-07-30 · **Estado**: **CERRADO — D-SND.1…D-SND.6 firmadas · F0 ✅ F1 ✅ F2 ✅ F3 ✅ F4 ✅ F5 ✅**. > Cierre: `$sound` existe, sema lo consume por puerto sin importarlo, `uix.sound` > está en la superficie pública y en la tabla ejecutable de contratos, y la > invariante de **un solo `AudioContext` por documento** está cerrada en los DOS > modos de arranque y **medida en Chrome** (§15). > **Auditoría posterior (§16): 5 hallazgos, los 5 resueltos** — A-1 y A-5 con > código, A-4 con tipos, A-2 y A-3 declarados por escrito tras medirlos. Lo que > deja como doctrina corregida: **S-1 nunca fue cerrable** por esta vía (son > 2,2 KB y el art es servicio de núcleo), y **la voz del earcon es diseño, no > maquinaria** — el corte se trazó en la frontera de módulos, no en la de > conocimiento (§16.1). > **18 suites / 225 tests verdes en `active-uix` + `sema` + el art · `check` en la baseline exacta (73, 0 propios).** > **Kickoff para sesión nueva**: *"Lee `docs/process/PLAN-audio-player.md`" — esta iniciativa ya no tiene trabajo pendiente.* > > **Revisión adversarial (2026-07-30)**: el plan pasó una revisión externa que > levantó 9 hallazgos, todos verificados contra el código y todos incorporados > como enmiendas E-1…E-7 (§14). Los tres de severidad alta: el cableado de > propiedad del engine no estaba escrito (E-1), faltaba la fila en la tabla > ejecutable de contratos (E-2), y el auto-suspend de F3 saboteaba al > reproductor —el consumidor que motiva el art— (E-3). > > **Origen**: análisis del reproductor de sonido (sesión 2026-07-30). Al buscar > dónde vivía el motor de audio del ecosistema apareció que **ya existe uno, sin > nombre de motor y en la carpeta equivocada**: 407 líneas de Web Audio dentro de > `sema/chans/sound.ts`. Decisión del usuario: **aparcar el reproductor y > extraer el motor primero**. La iniciativa del player queda bloqueada por ésta > ([`PLAN-audio-player.md`](./PLAN-audio-player.md)). > > **Antes de escribir código**: firmar §11. Lo de aquí son propuestas razonadas. --- ## 1. La deuda, medida [`sema/chans/sound.ts`](../../src/uix/sema/chans/sound.ts) — **407 líneas**, por naturaleza: | Bloque | ≈ Líneas | Qué es | | --- | --- | --- | | Ciclo de vida del contexto — `getOrCreateContext` · `createContext` · `primeContextFromGesture` · `setupUnlockListener` · `dispose` | 85 | **Motor** | | Síntesis — `synthesize` (2 osciladores + biquad lowpass + ADSR + modulador AM) · `applyContour` (±400 cents vía `detune`) | 115 | **Motor** | | Samples — `playSample` · `preloadSamples` · cache de `AudioBuffer` | 55 | **Motor** | | Pegamento de canal — `prepare` · `handle` · constructor · interfaz de opciones | 85 | Canal | | Docblock + tipos | 65 | — | **≈255 de 407 (≈63 %) es máquina Web Audio pura.** El código es bueno —el docblock explica por qué el ADSR se recorta al 15 %/40 % de la nota y por qué el sweep de contour subió de ±50 a ±400 cents— pero está en la carpeta equivocada. ## 2. Cuatro síntomas verificados de que es deuda, no diseño | # | Síntoma | Evidencia | | --- | --- | --- | | **S-1** | **Se paga siempre.** `engine.ts:29` importa `SoundChannel` como **valor estático**: toda app que construya un `EngineSemantic` mete el sintetizador en el bundle **aunque el sonido esté apagado** (que es el default — *sound and haptic default off*) | [`sema/engine.ts:29`](../../src/uix/sema/engine.ts) | | **S-2** | **Ciudadanía incompleta.** Sin suspensión en pestaña oculta, sin presupuesto de contextos (el navegador corta ~6), sin recuperación. `$scene` hace exactamente esa lista «una vez»: frame loop, pausa fuera de vista, DPR, pérdida de contexto, presupuesto, teardown | `sound.ts` completo vs [`arts/scene`](../../src/arts/scene/README.md) | | **S-3** | **Diagnóstico fuera de contrato.** Un solo `logger?.debug('sema.sound', …)` en el catch. El contrato de arts exige `diagnostics.ts` con catálogo tipado, `*_DIAGNOSTIC_EVENTS` en `consts.ts`, mensajes como constantes con nombre y errores tipados con guardas | [`sound.ts:138`](../../src/uix/sema/chans/sound.ts) vs [`arts/README.md`](../../src/arts/README.md) §Logger And Diagnostics Contract | | **S-4** | **La costura ya está abierta y no la usa nadie.** `audioContextFactory?: () => AudioContext \| null` existe como opción del constructor desde el principio — alguien ya vio que el contexto no debía ser propiedad del canal | [`sound.ts:47`](../../src/uix/sema/chans/sound.ts) | S-4 es el que cierra el caso: **es una deuda declarada sin escribir**. ## 3. El precedente — esto ya se hizo con motion No es arquitectura nueva. Es el mismo movimiento, documentado: > *«El runtime ya no vive en eidos: se relocalizó a `src/arts/motion` — un art — > expuesto como `uix.motion` y consumido por ambas capas. Esto disuelve el > acoplamiento soma→eidos. Eidos conserva la generación CSS y los DATOS de > presets/keyframes/signatures; los tipos, el motor y los drivers viven en > `$motion`.»* — [`theming/motion.md`](../theming/motion.md) | motion (hecho) | sound (este plan) | | --- | --- | | eidos conservó **datos**: keyframes · signatures · presets | sema conserva **doctrina**: `SOUND_LIBRARY` · `SOUND_TUNINGS` · resolvedores · cascada | | `$motion` se llevó el **motor**: registro · `run` · drivers | `$sound` se lleva el **motor**: contexto · síntesis · samples · unlock | | DOM por puerto estructural `MotionDom` | DOM por puerto estructural `SoundDom` | | `defineEngineMotion()`, `initMode: 'lazy'` | `defineEngineSound()`, idéntico | | Sin `dom` degrada (los drivers JS asientan) | Sin `dom` ni `AudioContext` degrada a silencio | Precedentes hermanos del mismo patrón: [`$ethereal`](../../src/arts/ethereal/README.md) (posicionamiento, ambas capas) y [`$scene`](../../src/arts/scene/README.md) (ciudadanía WebGL hecha una vez; efectos = recursos compartidos). **Nombre**: `$sound` / `EngineSound`. **No colisiona** con `$libs/sound`: el trío `$libs/{sound,motion,haptic}` son las dimensiones de preferencia (`allow|reduce`) y se queda intacto — igual que hoy conviven `$libs/motion` (prefs) y `$motion` (el art). ## 4. La línea de corte | `$sound` — `EngineSound` (art) | sema conserva | | --- | --- | | `AudioContext`: creación · unlock por gesto · resume · **suspend en pestaña oculta (nuevo)** · **presupuesto (nuevo)** · close | `SOUND_LIBRARY` (8 entradas) · `SOUND_TUNINGS` (13) | | Master `GainNode` | Los 3 resolvedores de gesto: `resolveHandleDragSound` · `resolveSliderDragSound` · `resolveSplitterDragSound` | | Síntesis: par de osciladores · biquad lowpass · ADSR · modulador AM · contour por `detune` | La cascada (5 capas) · memoria de frecuencia · árbitro de dominancia | | Samples: fetch · decode · cache de `AudioBuffer` · precarga · fallback a síntesis | La política de reducción (`SemaPreferences`, `SOUND_REDUCE_GAIN_FACTOR`): **resuelve el nivel y pasa una ganancia** | | Diagnostics + errores tipados (contrato de arts) | Qué firma para qué familia × intent | | **Cero** conocimiento de familias, intents, cascada, morfo o `SemaPreferences` | **Cero** máquina de plataforma | > ⚠️ **2026-08-12** — tres celdas de «sema conserva» quedaron rancias tras el > cierre de este plan: `SOUND_LIBRARY` y `SOUND_TUNINGS` se retiraron el > 2026-08-06 (el sonido se NOMBRA) y los 3 resolvedores de gesto el 2026-08-12 > ([`AUDIT-docs-code-ledger.md` §D10](./AUDIT-docs-code-ledger.md)) — los > gestos suenan por REPETICIÓN (`step` por emisión). La tabla se conserva como > registro del corte de 2026-07-30; la frontera viva es la misma: el art no > importa nada de sema. **Prueba objetiva del corte**: el art **no importa ni un tipo de `$uix/sema`**. Recibe un valor con forma de `SoundSignature` (9 campos numéricos + `contour` + `sampleUrl?`) y lo toca. Si algún día hace falta un import de sema, el corte está mal trazado. ## 5. API — restringida por el contrato de regresión (resultado de F0) ### 5.1 El contrato de regresión (RC) — leído de [`sound.test.ts`](../../src/uix/sema/chans/sound.test.ts), baseline 5/5 verde | ID | Lo que el test fija | | --- | --- | | **RC-1** | El **constructor no tiene efectos**: ni `dom.listen`, ni `audioContextFactory` | | **RC-2** | `prepare()` prima **síncronamente**: factory 1× · `createGain()` 1× · `gainNode.gain.value === masterGain` · `resume()` 1× · `getDocument()` **exactamente 1×** · `listen(document, ['pointerdown','mousedown','click','touchstart','keydown'], fn, true)` | | **RC-3** | `dispose()` → cleanup del listener 1× **+ `ctx.close()` 1×** | | **RC-4** | `preferences.sound === 'off'` → **el factory NUNCA se llama** (no se toca el audio en absoluto) | | **RC-5** | `preferences.sound === 'reduce'` con contexto `running` → `gain.value === masterGain × 0.4`, y **la ganancia se fija ANTES** de que la síntesis reviente sobre el fake context (sin `createOscillator`) y el throw se absorbe | | **RC-6** | `prepare()` con `channels: ['haptic']` explícito → no prima (ni factory ni listen) | ### 5.2 Lo que el RC impone al art (corrige la API que este plan proponía) 1. **Las opciones del art son un superconjunto de las del canal** — `audioContextFactory` · `fetchFn` · `dom` · `masterGain`. El harness inyecta por ahí; si el art no acepta esa superficie, RC-2/RC-4 no pueden pasar con los asserts intactos. 2. **Constructor sin efectos** (RC-1) — deja de ser preferencia y pasa a requisito verificado. 3. **`masterGain` es opción de construcción, no solo setter.** El plan proponía únicamente `setMasterGain()`; RC-2 exige el valor puesto en el nodo justo después de `prime()`. → **las dos cosas**. 4. **Orden observable dentro de `play()`**: fijar la ganancia **antes** de intentar sintetizar, y absorber el throw (RC-5). No es un detalle de implementación — está pineado. 5. **`prime()` es síncrono** (RC-2 no hace `await`): crea contexto + gain + listener y lanza `resume()` sin esperarlo. 6. **Solo el art registra el listener de unlock** — RC-2 exige `getDocument()` exactamente 1×; si el canal también lo llamara, serían 2. 7. **Regla de propiedad — *quien crea, dispone*.** RC-3 exige que `dispose()` del canal cierre el contexto, pero un engine **compartido**, inyectado por la raíz, no puede cerrarlo el canal. *(Corregido tras la revisión: F0 la declaró «regla NUEVA» y **no lo es** — es el idioma ya shipped del repo:* `ownsMotion` / `ownsScene` *en [`active-uix.svelte.ts:183,188`](../../src/uix/active-uix/active-uix.svelte.ts) con su `if (owns…) …dispose()` en `:559-560`. F2 solo escribe `ownsSound` en la misma línea; no se inventa nada.)* ### 5.4 Desviaciones conscientes del *movimiento puro* (declaradas, no escondidas) F1 no fue byte a byte. Tres diferencias respecto al original, todas deliberadas: | # | Desviación | Por qué | | --- | --- | --- | | 1 | Categoría de log `'sema.sound'` → `'sound.engine'` | El art no puede reclamar el namespace de sema. F3 lo sustituye por el catálogo de diagnostics | | 2 | `delay()` propio en lugar de `semaDelay` | El art no puede importar de `$uix/sema` — es la prueba objetiva del corte (§4). Misma lógica: scheduler si existe, `setTimeout` si no | | 3 | **Flag `disposed`** — el original tiene **cero** ocurrencias; tras `dispose()` un `handle()` posterior **recreaba** el contexto. El art se queda inerte, y su test pinea el comportamiento nuevo | Defendible como arreglo (`dispose` debería ser terminal), pero **es un cambio de conducta** dentro de una fase cuyo contrato era «movimiento puro». Ningún test lo observaba, por eso pasó. Si al revisar se prefiere fidelidad estricta, se quita el flag y se borra ese caso del test | **Veredicto de F0**: el criterio de D-SND.5 —*verde sin tocar un solo assert*— **es alcanzable**, y el split de §4 se sostiene: RC-4/RC-5/RC-6 pinean **política** (se queda en el canal) y RC-1/RC-2/RC-3 pinean **máquina** (se va al art). ### 5.3 La API resultante ```ts const sound = createEngineSound({ dom, // puerto SoundDom audioContextFactory, // RC-2/RC-4: costura de inyección fetchFn, // samples en test masterGain // RC-2: fijado en el nodo al crear el contexto }) sound.prime() // SÍNCRONO — crea + resume dentro del gesto (RC-2) await sound.play(signature) // gain primero, síntesis después, throw absorbido (RC-5) await sound.preload(urls) // decodifica samples a la cache sound.setMasterGain(0.4) // atenuación; la POLÍTICA la decide quien llama sound.suspend() / sound.resume() // pestaña oculta, control externo (F3) sound.dispose() // idempotente: listener + close + cache (RC-3) sound.state // 'absent' | 'suspended' | 'running' sound.context // AudioContext | null — la salida cruda ``` `sound.context` es la pieza que resuelve el problema que originó todo esto: **un solo contexto por documento**. Quien necesite grafo propio (analyser, ganancia >1, `BufferSource`, scheduling preciso) lo cuelga del mismo contexto en vez de abrir el segundo. Forma `Engine*`, no `Active*`: métodos públicos sobre estado privado, sin `$state` — como `EngineMotion` y `EngineScene`. Si algún día un medidor de nivel necesita estado reactivo, lo lee por rAF, no por runas. ## 6. El puerto ```ts export interface SoundDom { getDocument(node?: …): Document; listen(target: EventTarget, event: string | readonly string[], handler: EventListener, options?): () => void; // F3: visibilidad para el suspend en pestaña oculta } ``` El art no importa ningún otro art. `ActiveDom` satisface el puerto — misma forma que `MotionDom` / `SceneDom`. ## 7. Cómo lo consume sema sin romper ni una regla Un cambio, en [`engine.ts:284`](../../src/uix/sema/engine.ts): ```ts soundChannel = new SoundChannel({ ...soundOptions, engine: opts.soundEngine, // ← el art, inyectado por la raíz de composición dom: …, timers: …, preferences: …, logger: … }); ``` - **Sema no importa el art**: recibe un puerto estructural (`SoundOutput`) que `$sound` satisface. - **Sema no crea servicios**: los crea `ActiveUix` / `ActiveApp` — regla 1 de ownership ([`active-uix.md`](../architecture/active-uix.md)). - **Sigue degradando**: sin engine no hay sonido; sema sigue siendo ornamental y su contrato mínimo (`sound` opcional) no cambia. - **`SoundChannel` adelgaza a ~110 líneas**: resolver nivel → `engine.play(sig)`. Y el import estático deja de arrastrar el sintetizador — **cierra S-1**. ❌ **Falso, corregido en §16 (A-3)**: `chans/sound.ts` importa `createEngineSound` como VALOR para el fallback, así que la cadena `engine.ts → chans/sound.ts → $sound` sigue siendo estática. S-1 sigue abierto. ## 8. El háptico NO se extrae — la asimetría es correcta [`chans/haptic.ts`](../../src/uix/sema/chans/haptic.ts) son 175 líneas y su «motor» es **una llamada**: `navigator.vibrate(pattern)`. No hay contexto, ni grafo, ni cache, ni desbloqueo por gesto, ni presupuesto. **No hay máquina que extraer** — un `$haptic` sería un art vacío. Queda escrito para que nadie «arregle la asimetría» dentro de seis meses: *sound tiene motor porque sintetiza; haptic no lo tiene porque delega en una primitiva del sistema.* Se reevalúa el día que exista una Web Haptics API con patrones compuestos. ## 9. Consumidores (la barra de ≥2 se cumple) | Consumidor | Qué necesita | Estado | | --- | --- | --- | | **sema `SoundChannel`** | todo lo que ya usa | existe hoy | | **`media-player`** | `sound.context`: ganancia >1, analyser, precisión de segmento | [plan aparcado](./PLAN-audio-player.md) | | **`Waveform`** | `decodeAudioData` + cache para picos | diferido | | **Visualizador** | `AnalyserNode` → efecto de `$scene` (**los dos arts componen**) | diferido | | **`proof-of-human`** | reto de audio (registrado como v2) | componente existe | | **`Aura`** | la voz de la familia `delegate` | reservado | | **`chat-*`** | notas de voz | familia existe | Y el pago que ninguna referencia da: con un motor dueño del earcon **y** del contenido, el **ducking** (bajar la UI mientras suena el contenido) pasa de parche de cascada a capacidad del motor. ## 10. Lo que el art NO se lleva Audio espacial · cadenas de efectos/DSP · mezclador multipista · grabación · MIDI. El art es **un contexto bien gobernado + síntesis de earcon + reproducción de sample**. Todo lo demás cuelga de `sound.context`, que para eso se expone. ## 11. Decisiones (gate del usuario — SIN FIRMAR) | ID | Cuestión | Recomendación | | --- | --- | --- | | **D-SND.1** | ¿Se crea el art? ¿Con qué nombre? | **Sí** · `$sound` / `EngineSound` (convive con `$libs/sound` igual que `$motion` con `$libs/motion`) | | **D-SND.2** | La línea de corte (§4) | Tal cual, con la prueba objetiva: **el art no importa ni un tipo de `$uix/sema`** | | **D-SND.3** | ¿Se extrae también el háptico? | **No** — asimetría justificada por escrito (§8) | | **D-SND.4** | ¿Cómo lo consume sema? | Puerto estructural inyectado por la raíz. Sema no importa el art, no crea servicios, sigue degradando | | **D-SND.5** | Alcance del v1 del art | **Mover primero (value-preserving), arreglar después**: F3 cierra suspend-en-oculta, presupuesto y diagnostics. Mover-y-arreglar en el mismo pase es como se rompen los refactors | | **D-SND.6** | ¿`uix.sound` desde el día 1? | **Sí**: `defineEngineSound()` en `$active-app/service-factories` (`initMode: 'lazy'`) **y** `uix.sound` en `ActiveUix`, junto a `uix.motion` / `uix.timers` — es la superficie que el player y `Waveform` van a pedir | ## 12. Fases (tras el gate) | Fase | Contenido | Verify | | --- | --- | --- | | **F0 — Firma** ✅ **HECHA 2026-07-30** | D-SND.1–6 firmadas en la columna recomendada · `sound.test.ts` leído entero · **baseline 5/5 verde** · contrato de regresión RC-1…RC-6 derivado y §5 corregida con las 7 restricciones que impone (incl. la regla de propiedad *quien crea, dispone*, que no estaba en el plan) | ✅ `npx vitest run src/uix/sema/chans/sound.test.ts` → 5/5 | | **F1 — El art** ✅ **HECHA 2026-07-30** | `src/arts/sound/`: `types.ts` (puertos `SoundDom` + `SoundTimers`, `SoundSignature` propia) · `engine-sound.ts` (movimiento verbatim de contexto/síntesis/contour/samples + `delay` propio espejo de `semaDelay`) · `index.ts` (re-exports con nombre) · `README.md` · `engine-sound.test.ts` · alias `$sound` en `vite.config.ts` + `svelte.config.js`. **Movimiento puro**: cero cambios de comportamiento, cero imports de `$uix/sema` | ✅ suite del art **8/8** · ✅ `check` **0 errores propios** (los 73 del repo son preexistentes de la rama: `web/routes/alpha`, `demos/animations`, …) · ✅ baseline de sema intacta **5/5** · ⚠️ el alias `$sound` no se ejercita hasta F2 (nadie lo importa aún) | | **F2 — Sema consume** ✅ **HECHA 2026-07-30** — `SoundChannel` **407 → 136 líneas** · `soundEngine` en `EngineSemanticOptions` · `uix.sound` en ambos modos con `ownsSound` · fila `sound` en `contracts.ts` (+ `standaloneCreates` / `publicSurface`) · `defineEngineSound()` · `sound-port.test.ts` nuevo (guard de tipos E-6 + la regla de propiedad) | **(E-1) El cableado de propiedad, explícito** — `ActiveUix` crea `EngineSound` **incondicionalmente** (RC-1 lo hace gratis: sin contexto hasta `prime()`), lo expone como `uix.sound` y se lo pasa a `EngineSemantic` vía `opts.soundEngine`; en attach, `app.sound ?? createEngineSound(…)` + **`ownsSound`**, espejo literal de `ownsMotion`/`ownsScene`. El canal **solo crea el suyo si no se lo inyectan** (uso directo y tests — RC-3 sigue verde) · **(E-2)** fila `sound` en `contracts.ts` + su assert en `contracts.test.ts` · **(E-4)** resolver el `instanceof SoundChannel` del preload ([`engine.ts:296`](../../src/uix/sema/engine.ts)) → delegar `preloadSamples` al art, y **conservar viva** la vía `isChannel(opts.sound)` de `:281` · `SoundChannel` adelgaza a consumidor · `defineEngineSound()` | **(E-3, gate endurecido)** `vitest run src/uix/sema` — **las 14 suites**, no solo `sound.test.ts` — y **sin tocar un solo assert** · suite del art verde · `contracts.test.ts` verde · **(E-6)** guard de asignabilidad de tipos, del lado de sema (sema puede importar el art; el art a sema **nunca**) · navegador `/temas/sema`: comprobar **por JS que `uix.sound.context` es el MISMO objeto** que usa el canal (la prueba de que hay un solo contexto), que el unlock dispara, y oír 2-3 familias × intents

**RESULTADO**: ✅ sema **15/15 suites · 184/184**, sin tocar un assert de `sound.test.ts` · ✅ art **8/8** · ✅ `check` **73 errores, los mismos que antes de F2, 0 propios** · ✅ `contracts.test.ts` 35/38 — los 3 rojos son **preexistentes y ajenos** (`aura`, `menubar`, `radio-group`; el fichero ya venía modificado de otra sesión) y el guard que cubre esto —*pins ActiveUix public service names*— pasa · ✅ **camino de audio extremo a extremo**: [`sound-e2e.test.ts`](../../src/uix/sema/chans/sound-e2e.test.ts) — `emit → cascada → SoundChannel → $sound → grafo real` (2 osciladores + biquad + `start()`), el caso mudo (`channels:['haptic']`) y **el ciclo dispose/rebuild del estudio** ×3. 3/3
✅ **audible confirmado por el usuario** en `/temas/sema` tras limpiar el entorno.

**RETIRADA — la primera "verificación" de navegador era vacua.** Conté 1 `AudioContext` en `/temas/sema` y lo presenté como prueba de la invariante de contexto único. No lo era: esa página monta **su propio** `EngineSemantic` (`_lib/audition.ts:35-43`, `sound: true`) y el `createActiveUix` de la página pasa `sound: false`, así que el contexto contado era el del estudio y `uix.sound` no había creado ninguno (no tiene efectos). Medí una cosa y afirmé otra. La prueba buena es el test e2e de arriba; **la invariante de contexto único sigue sin verificarse en un navegador** y necesita una página que enchufe `soundEngine` (hoy ninguna lo hace — ver la nota de attach en F2). → **Cerrado en §15**: el estudio ya enchufa `uix.sound`, y la invariante está medida en Chrome (10 contextos → 1). | | **F3 — Ciudadanía** ✅ **HECHA 2026-07-30** — `autoSuspend` opt-in (default `false`, un solo `getDocument()` para los dos listeners, `suspend()`/`resume()` explícitos ganan sobre la política) · **el aviso se afinó al ejecutar**: no al construir un segundo ENGINE (inocuo — `ActiveUix` crea uno siempre por diseño) sino al ir vivo un segundo **CONTEXTO**, que es el anti-patrón real · `consts.ts` con categoría y mensajes con nombre · **sin `diagnostics.ts` ni `errors.ts`, y es decisión**: espejo de `$scene`, todo fallo aquí es degradación documentada, no error de programador · 3 tests nuevos (11/11, estables en 3 pasadas) | Cierre de S-2 y S-3: **(E-5) auto-suspend en pestaña oculta = OPT-IN** (`autoSuspend?: boolean`, default `false`) — suspender es política del que compone, no del motor: el earcon quiere callarse con la pestaña oculta y **un podcast NO**, y el player cuelga su grafo de este mismo contexto · **(E-5) el «presupuesto» se reformula**: `$scene` presupuesta N contextos GL concurrentes, pero este engine ES el singleton → lo útil es **avisar (warn) si se crea un segundo `EngineSound` en el documento**, que es justo el anti-patrón que originó el art · `diagnostics.ts` + `errors.ts` + `consts.ts` según el contrato de arts (sustituye la desviación 1 de §5.4) | tests nuevos · `check` · README del art completo | | **F4 — Docs** ✅ **HECHA 2026-07-30** | `arts/README.md`: fila del mapa + alias `$sound` + entrada en el grafo de dependencias · `architecture/sema.md`: la sección `SoundChannel` reescrita («doctrina aquí, máquina en `$sound`») + el árbol de ficheros · README del art ampliado con `autoSuspend` y el aviso de segundo contexto. `docs/README.md` NO tocado: el art se indexa desde `arts/README.md`, que es lo que E5 prescribe para artefactos | ⚠️ `docs:check` **1 error, ajeno y preexistente**: `eidos/components/callout/README.md:23` dice «8 roles» y `COLOR_ROLES.length` es 9. Fichero commiteado, sin relación con sonido — **no lo toco**, queda reportado | | **F5 — Desbloqueo** ✅ **HECHA 2026-07-30** | [`PLAN-audio-player.md`](./PLAN-audio-player.md) marcado **DESBLOQUEADO**; las 5 correcciones de su cabecera incorporadas al cuerpo; **D-AP.11 escrita contra el art** (su premisa —«no hay motor»— era falsa) y **D-AP.12** (segmentos) añadida — las dos existían como referencia sin fila en §4 | El plan del player queda listo para su gate de firma (D-AP.1…D-AP.12). Cero código del player en esta sesión | **Método**: no-cascada — una pieza, verificar, la siguiente ([`_cierre.md`](../audit/components/_cierre.md)). Gates completos al cerrar cada fase. ## 13. Riesgos declarados | Riesgo | Acotación | | --- | --- | | Tocar sema, la capa de doctrina más estricta | Refactor **value-preserving**: mismo sonido, mismo timing. `sound.test.ts` verde antes y después, **sin reescribir asserts** — si un assert hay que tocarlo, el movimiento dejó de ser puro | | Regresión perceptiva que los tests no oyen | Verificación en navegador con el laboratorio existente (`/temas/sema`), earcon a earcon. Los tests mockean el `AudioContext`; el oído no | | Sobre-alcance (mover + arreglar a la vez) | D-SND.5: dos fases separadas | | Que el art nazca sin consumidor | Ya tiene uno real (sema) y la barra de ≥2 la cumple el player, que es el motivo de la extracción | | Que alguien «arregle» la asimetría con el háptico | §8, por escrito | | **Levantar un dev server para verificar** | `npm run dev` es `vite dev --force`: **reescribe `node_modules/.vite/deps`** por debajo del servidor que el usuario ya tenía vivo → dos runtimes de Svelte → la página deja de hidratar (`lifecycle_outside_component`) → parece una regresión de audio que no existe. **Ocurrió el 2026-07-30 y costó una falsa alarma.** Verificar con tests deterministas (dobles de `AudioContext`), no levantando servidores | ## 14. Registro de la revisión adversarial (2026-07-30) Revisión externa del plan tras cerrar F1. **9 hallazgos, los 9 verificados contra el código, los 9 incorporados.** Se registra aquí porque tres de ellos cambian fases que aún no se han ejecutado, y porque dos corrigen afirmaciones que este mismo plan hacía. | Enmienda | Hallazgo | Dónde aterriza | | --- | --- | --- | | **E-1** | **ALTA** — F2 no decía **quién crea el engine** en los dos modos de boot. Con `uix.sound` creando uno y el canal creando otro cuando no se le inyecta, se vuelve a **dos contextos**: el anti-patrón original, ahora con dos dueños «legítimos» | F2 · resuelto con el idioma ya shipped (`ownsSound`, espejo de `ownsMotion`/`ownsScene`) | | **E-2** | **ALTA** — `uix.sound` toca [`contracts.ts`](../../src/uix/contracts.ts), la tabla EJECUTABLE de contratos mínimos, cuya propia doctrina dice que *«future changes must derive from this table»*. Añadir un servicio sin su fila la viola | F2 · fila `sound` + assert | | **E-3** | **ALTA** — el auto-suspend de F3 **sabotea al reproductor**: la gente escucha podcasts con la pestaña oculta, y el player cuelga su grafo de este contexto. Política correcta para earcons, incorrecta para contenido | F3 · opt-in, default `false` | | **E-4** | MEDIA — el `instanceof SoundChannel` que guarda el preload de los packs ([`engine.ts:296`](../../src/uix/sema/engine.ts)) y la vía `isChannel(opts.sound)` de `:281` eran cabos sueltos: romperían el preload en silencio | F2 | | **E-5** | BAJA — el «presupuesto de contextos» venía copiado de `$scene` sin caso propio: aquí el engine ES el singleton | F3 · warn al segundo engine | | **E-6** | MEDIA — `SoundSignature` está duplicada sin guard: la asignabilidad estructural es silenciosa en la dirección peligrosa (un campo nuevo en sema se ignora sin error) | F2 · guard de tipos **del lado de sema** | | **E-7** | DOC — la cabecera decía «F0 pendiente — SIN FIRMAR» con F0 y F1 ya cerradas; y las desviaciones del *movimiento puro* no estaban declaradas | cabecera · §5.4 | **Dos correcciones que la revisión provocó sobre el propio plan:** 1. **La «regla de propiedad NUEVA» de F0 no era nueva.** `ownsMotion` / `ownsScene` ([`active-uix.svelte.ts:183,188,559-560`](../../src/uix/active-uix/active-uix.svelte.ts)) ya es el idioma del repo. F2 lo copia; no inventa. 2. **F1 tenía una tercera desviación sin declarar** que la revisión externa tampoco vio: el flag `disposed` (§5.4, fila 3). El original recreaba el contexto tras `dispose()`; el art se queda inerte, y su propio test pinea el comportamiento nuevo. Ningún test lo observaba — por eso pasó. ## 15. La invariante, cerrada y MEDIDA (2026-07-30, sesión de cierre) F2 dejó dos deudas escritas: la invariante de contexto único **sin verificar en navegador**, y ninguna página que enchufase `soundEngine`. Las dos quedan cerradas aquí, y por el camino apareció un tercer agujero que nadie había visto. ### 15.1 Lo que la medición desmintió El handoff anterior predecía que abrir `/temas/sema` **avisaría por consola** del segundo contexto. **No avisa, y no debía**: el aviso cuenta contextos VIVOS, y en esa página sólo había uno — el privado del estudio. `uix.sound` no abría ninguno porque nada lo primaba (`events: { sound: false }`), que es exactamente lo que la retirada de F2 ya había reconocido. Predicción incorrecta, comprobada antes de tocar nada. ### 15.2 Lo que sí estaba roto — el trasiego de contextos Instrumentando `AudioContext` en Chrome sobre el servidor del usuario (sin levantar otro — §13), con 8 ciclos de *editar el draft → disparar*: | | contextos creados | cerrados | vivos | | --- | --- | --- | --- | | **Antes** (motor privado del canal) | **10** | 9 | 1 | | **Después** (`soundEngine: uix.sound`) | **1** | 0 | 1 | Cada edición del draft reconstruye el `EngineSemantic` → `dispose()` del canal → `ctx.close()` → el siguiente disparo abre uno nuevo. Además de la invariante, se perdía **la caché de samples decodificados** en cada edición (el `.wav` importado se volvía a bajar y decodificar). Los osciladores siguen construyéndose en cada disparo después del arreglo, así que el earcon no se perdió por el camino. Arreglo: [`web/routes/temas/sema/_lib/audition.ts`](../../web/routes/temas/sema/_lib/audition.ts) acepta `soundEngine` y [`+page.svelte`](../../web/routes/temas/sema/+page.svelte) le pasa `uix.sound`. Guard determinista: *«an injected engine survives the rebuilds: ONE context for the whole page»* en [`sound-e2e.test.ts`](../../src/uix/sema/chans/sound-e2e.test.ts). ### 15.3 E-8 — el modo attach tenía la invariante ABIERTA (hallazgo nuevo) E-1 dio por cableada la propiedad «en los dos modos de arranque». En attach **no lo estaba**: [`defineUixServices`](../../src/uix/active-uix/services.ts) registraba `motion` y `scene` junto a `dom` pero **no `sound`**, y [`defineEngineSemantic`](../../src/uix/sema/define-engine-semantic.ts) no reenviaba `soundEngine`. Una app en attach con sonido activo tenía el motor privado del canal **más** `uix.sound` — dos motores, y dos contextos en cuanto el reproductor primase el suyo. `attachActiveUix` **documentaba** el footgun en un comentario en vez de cerrarlo, y `contracts.ts` ya prometía `singleContextPerDocument: true`. Cerrado (decisión del usuario, 2026-07-30): `defineUixServices` registra `sound`, `defineEngineSemantic` lo declara como `serviceDependencies: ['dom', 'sound']` y lo reenvía (degrada a `undefined` como `dom`), la nota de `attachActiveUix` pasa a describir el cierre, y la fila `events` de `contracts.ts` gana `serviceDependencies` — de la que el assert ahora deriva en vez de hardcodear. Guard: *«routes the app events engine through the app sound service (ONE AudioContext)»* en [`active-uix.svelte.test.ts`](../../src/uix/active-uix/active-uix.svelte.test.ts). **Verificado que no es vacuo**: quitando el reenvío, el test falla (`expected null not to be null`) — la lección de la retirada de F2, aplicada. ## 16. Auditoría posterior al cierre — 5 hallazgos ABIERTOS (2026-07-30) Al releer lo entregado **cuestionando el diseño y no sólo su coherencia con este plan**, aparecen cinco cosas. Ninguna invalida lo commiteado; tres necesitan decisión del usuario. Se escriben aquí porque este plan afirma cosas que estos hallazgos corrigen. | # | Hallazgo | Evidencia | | --- | --- | --- | | **A-1** ✅ **CERRADO 2026-07-30** (§16.3) | **El estudio SIGUE abriendo un segundo contexto.** [`analyze.ts:32`](../../web/routes/temas/sema/_lib/analyze.ts) hace `new Ctor()` propio para decodificar el `.wav` importado. Medido inyectando un WAV real por el `input[type=file]`: **1 → 2 contextos** (el segundo se cierra al acabar el decode). La medición de §15 sólo recorría el camino del earcon | navegador | | **A-2** ✅ **ESCRITO 2026-07-30** (no se puede «arreglar» sin parchear el constructor global; queda declarado en `consts.ts` y en el README) | **El aviso de «segundo contexto» es ciego a los contextos crudos.** `liveContexts` cuenta sólo los que crea el art, así que un `new AudioContext()` en cualquier otro sitio —A-1, exactamente— es invisible. Detecta cableado doble de *engines*, no el antipatrón que su mensaje nombra | [`engine-sound.ts:60`](../../src/arts/sound/engine-sound.ts) | | **A-3** ✅ **ACEPTADO 2026-07-30** (§16.4) | **S-1 NO está cerrado**, y §7 afirma lo contrario. `engine.ts:29` importa `SoundChannel` como valor y `chans/sound.ts:7` importa `createEngineSound` como valor: toda app que construya un `EngineSemantic` sigue metiendo el sintetizador en el bundle con el sonido apagado. La cadena es un salto más larga e igual de estática. Y «sema no importa el art» es inexacto: cierto para la *instancia*, falso para el *grafo de módulos* | [`engine.ts:29`](../../src/uix/sema/engine.ts) · [`sound.ts:7`](../../src/uix/sema/chans/sound.ts) | | **A-4** ✅ **CERRADO 2026-07-30** (§16.5) | **El canal tira en silencio las opciones de construcción cuando le inyectan el motor**: `masterGain`, `audioContextFactory`, `fetchFn`, `dom`, `timers`, `logger`. Sólo conserva `preferences`. En standalone pasa desde F2; **el cableado de attack de §15.3 lo extiende a attach**, donde `sound: { masterGain: 0.4 }` era honrado y ahora es un no-op mudo. Ningún test lo pinea. Efecto lateral: el nombre de servicio `sound` queda reservado de facto | [`sound.ts:80`](../../src/uix/sema/chans/sound.ts) | | **A-5** ✅ **CERRADO 2026-07-30** (§16.6) | **`gainScale` —parámetro de UNA reproducción— se escribe en el nodo master COMPARTIDO**, del que cuelgan todos los earcons, incluidos los que ya suenan. Con un consumidor no se nota; con dos —la premisa del art— el ducking del reproductor y la reducción de sema se pisan, y un earcon en vuelo salta de nivel a media nota. El grafo ya tiene su nodo de envolvente, que es el sitio correcto. **`RC-5` pineó ese orden como si fuera diseño** | [`engine-sound.ts:141`](../../src/arts/sound/engine-sound.ts) | ### 16.1 Y la pregunta de fondo: ¿el corte está bien trazado? La prueba objetiva de §4 —*el art no importa ni un tipo de `$uix/sema`*— se cumple **en la frontera de módulos, no en la de conocimiento**. Dentro de la «maquinaria» hay cuatro decisiones perceptivas calibradas contra el vocabulario de sema: la **quinta** a 1.5× mezclada a 0.3, el **ADSR recortado a 15 %/40 %** (*«una nota de commit son 100 ms…»*), el **contour de ±400 cents** (*«±50 era imperceptible a las duraciones de hold típicas»*) y el mapeo de **`roughness` a profundidad y velocidad de AM**. El art no importa sema: sema está horneada dentro del art como constantes. Compárese con el precedente que §3 invoca: en `$motion` la capa conservó los DATOS (keyframes, presets, curvas) y el art se llevó MECANISMOS genéricos (drivers). Aquí el mecanismo se llevó también el diseño. **El corte de sound no replica el de motion tan de cerca como este plan afirma.** Y la superficie lo confirma: `prime`/`suspend`/`resume`/`context`/`dispose` es **gobierno de contexto** (genérico, con consumidores reales hoy); `play(signature)` es **la voz de la UI** (un solo consumidor, y calibrada para él). Son dos artefactos pegados. Peor: la API **no puede servir a los consumidores que la justificaron**. `Waveform` necesita los `AudioBuffer` para leer picos y `preload(urls)` los esconde en una caché privada; el analizador del estudio necesita decodificar un `File` y no hay entrada por bytes — **por eso A-1 abre su propio contexto**. De los tres consumidores que la barra de ≥2 invocaba, la superficie sólo sirve a uno (el player, vía `context`). ### 16.2 Lo propuesto, por orden de valor > **Estado: los 5 aplicados** (firmados 2026-07-30). El punto 5 de esta lista > resultó **incorrecto** al medirlo — quitar el fallback no cierra S-1, porque > las raíces de composición importan el art igual. Corregido en §16.4. 1. **`decode(bytes): Promise` en el art**, y devolver los buffers cacheados. Cierra A-1, es lo que `Waveform` va a pedir, y convierte la barra de ≥2 en algo real HOY en vez de una promesa. 2. **Mover `gainScale` del master al grafo del earcon** (A-5), reescribiendo `RC-5` — que es justo lo que un contrato de regresión debe permitir cuando lo que pineaba era un defecto. 3. **Declarar las cuatro constantes como *la voz del art*, no como maquinaria**. Si algún día un segundo consumidor quiere otra voz, ese es el momento de partir el art en dos: gobierno de contexto + voz. No antes. 4. **Decidir A-4**: que las opciones explícitas ganen al motor inyectado, o que avisen. Y reservar por escrito el nombre de servicio `sound`. 5. **A-2 y A-3 se escriben donde prometen de más** (README del art, §2, §7) en cuanto se decida 1–4; A-3 se cierra de verdad quitando el fallback del canal o difiriéndolo con `import()`, que tras §15.3 ya no lo alcanza ninguna raíz de composición. **No se recomienda rehacer la extracción.** Fue value-preserving, está verde, y mover la síntesis otra vez no paga el riesgo. ### 16.3 A-1, cerrado — `decode()` en el art (2026-07-30) `EngineSound.decode(data: ArrayBuffer): Promise`: la segunda puerta del art, la que faltaba para los consumidores que quieren **las muestras** y no un grafo. `analyze.ts` la usa y deja de abrir su contexto; `AnalyzePanel` toma el motor de `getActiveUix()`, no por prop-drilling. Decisiones dentro del arreglo, declaradas: - **No pasa por `getOrCreateContext()`.** Decodificar funciona sobre un contexto suspendido, y `resume()` fuera de un gesto puede quedarse pendiente para siempre: un `decode()` que se espera desde la UI no puede colgar de eso. Se añadió `ensureContext()` — crear sin resumir. - **No cachea.** Los bytes crudos no tienen clave; la caché por URL sigue siendo de `preload`. Y devuelve `null` en vez de lanzar. - **Desviación consciente de §16.2 punto 1**: NO se añadió el acceso a los buffers cacheados. Hoy no tiene consumidor —`Waveform` no existe— y añadir superficie sin consumidor es justo lo que este plan critica en §16.1. **Medido en Chrome**, importando un WAV real por el `input[type=file]`: | | contextos creados al importar | | --- | --- | | antes | **2** (el privado del decode se cerraba al acabar) | | ahora | **1**, y el earcon posterior reutiliza ése | Y sigue funcionando: un tono de 440 Hz a 8 kHz, decodificado ahora sobre el contexto compartido a 44,1 kHz, se mide como **441 Hz** — el resampleo no falsea el análisis. Guards: dos casos nuevos en `engine-sound.test.ts` (decodifica sobre el contexto compartido sin llamar a `resume()`, y el segundo `decode` **no** crea otro contexto; `null` en bytes indecodificables y sin contexto). ### 16.4 A-3, ACEPTADO — S-1 no es cerrable por esta vía, y cuesta 2,2 KB **Medido**: el art entero son **6.410 bytes minificados · 2.177 gzip**. Eso es todo el síntoma S-1. Y la propuesta de §16.2 punto 5 —«quitar el fallback del canal»— **era incorrecta**. Los sitios que importan el art como VALOR son tres, y dos son incondicionales por diseño: | Sitio | ¿Se puede quitar? | | --- | --- | | [`chans/sound.ts`](../../src/uix/sema/chans/sound.ts) — el fallback | sí, pero rompe `sound.test.ts`, que construye por esa vía | | [`active-uix.svelte.ts:40`](../../src/uix/active-uix/active-uix.svelte.ts) — los dos modos de arranque | **no**: crea el motor incondicionalmente por diseño | | [`service-factories/sound.ts`](../../src/arts/active-app/service-factories/sound.ts) — `defineEngineSound()` | **no**, y §15.3 lo metió además en `defineUixServices` | Quitar el fallback dejaría el art fuera del bundle sólo de una app que no use `createActiveUix` **ni** `defineUixServices` — es decir, de ninguna. Cerrarlo de verdad exige carga diferida, y ahí choca con que **`prime()` debe ser síncrono dentro del gesto**: un `await import()` en el handler llega tarde y el contexto se queda suspendido. Habría que precargar el chunk antes del primer gesto (se descarga igual) y volver `uix.sound` perezoso, cambiando la superficie pública que `contracts.ts` declara como `readonly sound: EngineSound` siempre presente. **Decisión: aceptar.** El art es un servicio del núcleo, como `motion` y `scene`, y 2,2 KB no pagan un cambio de superficie pública. Lo que sí queda corregido es la doctrina: **S-1 era el más débil de los cuatro síntomas**; los que justificaban la extracción eran S-2 (ciudadanía) y S-4 (la costura de inyección declarada sin escribir), más el reparto doctrina/máquina. ### 16.5 A-4, cerrado — unión discriminada, aviso, y `fetchFn` → `fetcher` `SoundChannelOptions` deja de ser un objeto plano y pasa a **unión de dos formas**: `{ engine, preferences?, logger? }` (la raíz es dueña del motor) **o** la forma de construcción con `audioContextFactory` / `fetcher` / `dom` / `masterGain` / `timers`. Nunca ambas: un motor llega ya construido, así que sus opciones de construcción no significan nada a su lado. - **La defensa primaria es el tipo** —doctrina de la casa, *types over lint*— con un guard `@ts-expect-error` en `sound-port.test.ts` que falla `check` el día que la unión deje de rechazar la forma mezclada. - **Aviso por logger** para quien llame desde JS, enumerando las claves ignoradas. El silencio fue lo que hizo que esto pasara desapercibido. - `engine.ts` pasa de un spread a **tres ramas** en orden de precedencia: motor del canal → motor de la raíz → el canal construye el suyo. - **`fetchFn` → `fetcher`**, para alinearse con `$perm` ([types.ts:49](../../src/arts/perm/types.ts)) — un nombre por concepto. Sobre el otro nombre discutido: **`audioContextFactory` se queda**; es el patrón `idFactory` que ya usan `$logger` y `$bus`, y nombra el tipo de plataforma exacto que fabrica (`context` a secas colisionaría con el contexto GL de `$scene`). `sound.test.ts` **no se toca por A-4**: construye siempre por la forma B. ### 16.6 A-5, cerrado — `gainScale` a la envolvente, y RC-5 reescrito `play()` ya no escribe el master. `gainScale` multiplica el pico de la envolvente de ESA reproducción (`synthesize`) o la ganancia del sample (`playSample`), y **también la profundidad del AM** — si no, un earcon atenuado sonaría relativamente más áspero: cambiaría el timbre en vez del volumen, que es lo contrario de lo que significa una reducción. **RC-5 se reescribe, y es legítimo**: un contrato de regresión protege comportamiento, no defectos, y lo que pineaba era *«la ganancia se fija ANTES de que la síntesis reviente»* — un orden que sólo importaba porque el valor iba a un nodo compartido. La cobertura no se pierde, se coloca: | Dónde | Qué pinea ahora | | --- | --- | | `sound-port.test.ts` (ya existía) | el canal resuelve el NIVEL y lo entrega como `gainScale` | | `sound.test.ts` (reescrito) | la reducción es por señal: **el master no se mueve** | | `engine-sound.test.ts` (nuevo, con un contexto falso que sí construye el grafo) | el pico de la envolvente es `gain × gainScale` y el master conserva su valor de política · el AM escala con el nivel | Los dos guards se verificaron **viéndolos fallar**: reintroduciendo la escritura al master, caen `sound.test.ts` y `engine-sound.test.ts`.