You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/docs/process/PLAN-sound-engine.md

550 lines
44 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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<br><br>**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<br>✅ **audible confirmado por el usuario** en `/temas/sema` tras limpiar el entorno.<br><br>**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<AudioBuffer | null>` 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<AudioBuffer | null>`: 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`.

Powered by TurnKey Linux.