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

refactor(sound): extraer el motor Web Audio de sema al art `$sound` `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 <noreply@anthropic.com>
2 months ago
# 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).
docs(process): el motor cierra sus fases y abre 5 hallazgos; el player, al gate `PLAN-sound-engine` pasa a CERRADO (F0…F5) y `PLAN-audio-player` a DESBLOQUEADO. Pero cerrar las fases no es dejar el diseño incuestionado, así que el plan gana una auditoría propia que lo contradice donde toca. - **§15 — la invariante, cerrada y medida.** Incluye lo que la medición DESMINTIÓ: el handoff anterior predecía un aviso por consola en `/temas/sema` que no podía aparecer, porque el aviso cuenta contextos VIVOS y allí sólo había uno. Y **E-8**, el agujero de attach que E-1 dio por cableado sin estarlo. - **§16 — 5 hallazgos ABIERTOS sobre el art**, tres con decisión pendiente: `analyze.ts` sigue abriendo su contexto (medido 1 → 2 al importar un `.wav`); el aviso de segundo contexto es ciego a los contextos crudos; **S-1 NO está cerrado** y §7 afirmaba lo contrario —queda tachado en su sitio—; el canal tira en silencio `masterGain` y compañía cuando le inyectan el motor; y `gainScale`, que es de UNA reproducción, se escribe en el master compartido. - **§16.1 — la pregunta de fondo**: el corte separa módulos, **no conocimiento**. La quinta a 1.5×, el ADSR recortado a 15 %/40 %, el contour de ±400 cents y el mapeo de `roughness` son diseño sonoro calibrado contra el vocabulario de sema, viviendo dentro de lo que el plan llama «maquinaria». El precedente de `$motion` —datos en la capa, mecanismos en el art— no se replica tan de cerca como §3 afirma. Y la superficie no sirve a dos de los tres consumidores que la justificaban: `Waveform` y el analizador necesitan los `AudioBuffer`, y `preload(urls)` los esconde en una caché privada. - **`sema.md` corregido**: «sema no importa el art» era cierto para la INSTANCIA y falso para el grafo de módulos. - **`PLAN-audio-player` con las 5 correcciones de su cabecera incorporadas al cuerpo**, que era la condición para presentar nada: segmentos en la matriz §2 y en el contrato §7.5; **G-2 sube a bloqueante** (buffer, ventana de clip, capítulos); las dos invariantes de §7.6 (`prefs.sound` NUNCA toca el volumen del contenido; el silencio de UI debe alcanzar al `Slider` compuesto). **D-AP.11 escrita contra el art** —su premisa, «no hay motor», era falsa— y **D-AP.12** (segmentos `clip`) añadida; las dos existían como referencia sin fila en §4. **D-AP.7 reescrita** como ducking. - El handoff añade dos reglas que costaron caro: **un guard que no has visto fallar no vale**, y acoplarse al dev server vivo del usuario en vez de levantar otro. D-AP.1…D-AP.12 siguen SIN FIRMAR: el gate es lo primero de F0 y no se escribe una línea de código del player antes. `docs:check` deja 1 error ajeno (`blocks/banner/README.md:15`, «8 roles» por 9). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
> **Fecha**: 2026-07-30 · **Estado**: **CERRADO — D-SND.1…D-SND.6 firmadas · F0 ✅ F1 ✅ F2 ✅ F3 ✅ F4 ✅ F5 ✅**.
refactor(sound): extraer el motor Web Audio de sema al art `$sound` `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 <noreply@anthropic.com>
2 months ago
> Cierre: `$sound` existe, sema lo consume por puerto sin importarlo, `uix.sound`
docs(process): el motor cierra sus fases y abre 5 hallazgos; el player, al gate `PLAN-sound-engine` pasa a CERRADO (F0…F5) y `PLAN-audio-player` a DESBLOQUEADO. Pero cerrar las fases no es dejar el diseño incuestionado, así que el plan gana una auditoría propia que lo contradice donde toca. - **§15 — la invariante, cerrada y medida.** Incluye lo que la medición DESMINTIÓ: el handoff anterior predecía un aviso por consola en `/temas/sema` que no podía aparecer, porque el aviso cuenta contextos VIVOS y allí sólo había uno. Y **E-8**, el agujero de attach que E-1 dio por cableado sin estarlo. - **§16 — 5 hallazgos ABIERTOS sobre el art**, tres con decisión pendiente: `analyze.ts` sigue abriendo su contexto (medido 1 → 2 al importar un `.wav`); el aviso de segundo contexto es ciego a los contextos crudos; **S-1 NO está cerrado** y §7 afirmaba lo contrario —queda tachado en su sitio—; el canal tira en silencio `masterGain` y compañía cuando le inyectan el motor; y `gainScale`, que es de UNA reproducción, se escribe en el master compartido. - **§16.1 — la pregunta de fondo**: el corte separa módulos, **no conocimiento**. La quinta a 1.5×, el ADSR recortado a 15 %/40 %, el contour de ±400 cents y el mapeo de `roughness` son diseño sonoro calibrado contra el vocabulario de sema, viviendo dentro de lo que el plan llama «maquinaria». El precedente de `$motion` —datos en la capa, mecanismos en el art— no se replica tan de cerca como §3 afirma. Y la superficie no sirve a dos de los tres consumidores que la justificaban: `Waveform` y el analizador necesitan los `AudioBuffer`, y `preload(urls)` los esconde en una caché privada. - **`sema.md` corregido**: «sema no importa el art» era cierto para la INSTANCIA y falso para el grafo de módulos. - **`PLAN-audio-player` con las 5 correcciones de su cabecera incorporadas al cuerpo**, que era la condición para presentar nada: segmentos en la matriz §2 y en el contrato §7.5; **G-2 sube a bloqueante** (buffer, ventana de clip, capítulos); las dos invariantes de §7.6 (`prefs.sound` NUNCA toca el volumen del contenido; el silencio de UI debe alcanzar al `Slider` compuesto). **D-AP.11 escrita contra el art** —su premisa, «no hay motor», era falsa— y **D-AP.12** (segmentos `clip`) añadida; las dos existían como referencia sin fila en §4. **D-AP.7 reescrita** como ducking. - El handoff añade dos reglas que costaron caro: **un guard que no has visto fallar no vale**, y acoplarse al dev server vivo del usuario en vez de levantar otro. D-AP.1…D-AP.12 siguen SIN FIRMAR: el gate es lo primero de F0 y no se escribe una línea de código del player antes. `docs:check` deja 1 error ajeno (`blocks/banner/README.md:15`, «8 roles» por 9). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
> 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).
fix(sound): la auditoría del art, cerrada — niveles por reproducción y opciones que no mienten Los tres hallazgos que quedaban de `PLAN-sound-engine` §16, firmados y aplicados. Dos con código, uno con una medición que cambia la doctrina. **A-5 — `gainScale` es de la reproducción, no del documento.** `play()` escribía `master × gainScale` en el nodo master, del que cuelgan TODOS los earcons. Un parámetro transitorio corrompía un mando de política: el nivel se filtraba entre reproducciones, una nota en vuelo saltaba de volumen, y los dos consumidores que motivan el motor compartido —la reducción de sema y el ducking de un reproductor— se pisaban sobre un único valor. Ahora el factor multiplica el pico de la envolvente de ESA reproducción, y **también la profundidad del AM**: dejarla absoluta habría hecho que un earcon atenuado sonara relativamente más áspero, cambiando 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. Lo que pineaba —«la ganancia se fija ANTES de que la síntesis reviente»— sólo importaba porque el valor iba a un nodo compartido. La cobertura no se pierde, se coloca donde vive cada responsabilidad: `sound-port.test.ts` ya pineaba que el canal resuelve el NIVEL y lo entrega; `sound.test.ts` pinea ahora que el master **no se mueve**; y el art estrena un contexto falso que sí construye el grafo, para ver el pico de la envolvente. **A-4 — las opciones del canal, imposibles de equivocar.** `SoundChannelOptions` pasa a unión discriminada: `{ engine, preferences?, logger? }` **o** la forma de construcción (`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, y hasta hoy se aceptaban y se tiraban en silencio. La defensa primaria es el tipo (doctrina de la casa), con un guard `@ts-expect-error` que hace fallar `check` el día que la unión deje de rechazar la mezcla; el aviso por logger es la red para JS. `engine.ts` pasa de un spread a tres ramas en orden de precedencia. Y **`fetchFn` → `fetcher`**, para alinearse con `$perm`: un nombre por concepto. `audioContextFactory` **se queda** — es el patrón `idFactory` que ya usan `$logger` y `$bus`, y nombra el tipo exacto que fabrica (`context` a secas colisiona con el contexto GL de `$scene`). **A-3 — S-1 aceptado, medido: 2,2 KB.** El art entero son 6.410 bytes minificados / 2.177 gzip. Y mi propia propuesta para cerrarlo era falsa: quitar el fallback del canal no saca el art de ningún bundle, porque `createActiveUix` y `defineEngineSound` lo importan incondicionalmente. Cerrarlo de verdad exige carga diferida, que choca con que `prime()` deba ser síncrono dentro del gesto, y volver `uix.sound` perezoso — cambio de superficie pública por 2,2 KB. Se acepta: el art es servicio de núcleo, como `motion` y `scene`. Queda corregida la doctrina: S-1 era el más débil de los cuatro síntomas; los que justificaban la extracción eran S-2 y S-4. **La voz, declarada como diseño.** La quinta a 1.5×, el ADSR recortado a 15 %/40 %, el contour de ±400 cents y el mapeo del AM son decisiones perceptivas calibradas contra el vocabulario de sema, no maquinaria. Escrito en el README del art y en la cabecera de `engine-sound.ts`, con la regla: el día que un segundo consumidor quiera otra voz, ése es el momento de partir el art en dos —gobierno de contexto / voz—, no antes. Verificación: art + sema **17 suites / 204 tests** · `contracts.test.ts` 35/38 (los 3 rojos, ajenos) · `check` en la baseline exacta (73, 0 propios) · `docs:check` **0 errores** · los guards de A-5 **vistos fallar** al reintroducir la escritura al master · navegador `/temas/sema`, 4 ciclos de editar→disparar: **1 contexto, 16 osciladores, master en 1, cero errores de consola**. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
> **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).
docs(process): el motor cierra sus fases y abre 5 hallazgos; el player, al gate `PLAN-sound-engine` pasa a CERRADO (F0…F5) y `PLAN-audio-player` a DESBLOQUEADO. Pero cerrar las fases no es dejar el diseño incuestionado, así que el plan gana una auditoría propia que lo contradice donde toca. - **§15 — la invariante, cerrada y medida.** Incluye lo que la medición DESMINTIÓ: el handoff anterior predecía un aviso por consola en `/temas/sema` que no podía aparecer, porque el aviso cuenta contextos VIVOS y allí sólo había uno. Y **E-8**, el agujero de attach que E-1 dio por cableado sin estarlo. - **§16 — 5 hallazgos ABIERTOS sobre el art**, tres con decisión pendiente: `analyze.ts` sigue abriendo su contexto (medido 1 → 2 al importar un `.wav`); el aviso de segundo contexto es ciego a los contextos crudos; **S-1 NO está cerrado** y §7 afirmaba lo contrario —queda tachado en su sitio—; el canal tira en silencio `masterGain` y compañía cuando le inyectan el motor; y `gainScale`, que es de UNA reproducción, se escribe en el master compartido. - **§16.1 — la pregunta de fondo**: el corte separa módulos, **no conocimiento**. La quinta a 1.5×, el ADSR recortado a 15 %/40 %, el contour de ±400 cents y el mapeo de `roughness` son diseño sonoro calibrado contra el vocabulario de sema, viviendo dentro de lo que el plan llama «maquinaria». El precedente de `$motion` —datos en la capa, mecanismos en el art— no se replica tan de cerca como §3 afirma. Y la superficie no sirve a dos de los tres consumidores que la justificaban: `Waveform` y el analizador necesitan los `AudioBuffer`, y `preload(urls)` los esconde en una caché privada. - **`sema.md` corregido**: «sema no importa el art» era cierto para la INSTANCIA y falso para el grafo de módulos. - **`PLAN-audio-player` con las 5 correcciones de su cabecera incorporadas al cuerpo**, que era la condición para presentar nada: segmentos en la matriz §2 y en el contrato §7.5; **G-2 sube a bloqueante** (buffer, ventana de clip, capítulos); las dos invariantes de §7.6 (`prefs.sound` NUNCA toca el volumen del contenido; el silencio de UI debe alcanzar al `Slider` compuesto). **D-AP.11 escrita contra el art** —su premisa, «no hay motor», era falsa— y **D-AP.12** (segmentos `clip`) añadida; las dos existían como referencia sin fila en §4. **D-AP.7 reescrita** como ducking. - El handoff añade dos reglas que costaron caro: **un guard que no has visto fallar no vale**, y acoplarse al dev server vivo del usuario en vez de levantar otro. D-AP.1…D-AP.12 siguen SIN FIRMAR: el gate es lo primero de F0 y no se escribe una línea de código del player antes. `docs:check` deja 1 error ajeno (`blocks/banner/README.md:15`, «8 roles» por 9). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
> **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.*
refactor(sound): extraer el motor Web Audio de sema al art `$sound` `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 <noreply@anthropic.com>
2 months ago
>
> **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 |
2 months ago
> ⚠️ **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.
refactor(sound): extraer el motor Web Audio de sema al art `$sound` `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 <noreply@anthropic.com>
2 months ago
**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**.
docs(process): el motor cierra sus fases y abre 5 hallazgos; el player, al gate `PLAN-sound-engine` pasa a CERRADO (F0…F5) y `PLAN-audio-player` a DESBLOQUEADO. Pero cerrar las fases no es dejar el diseño incuestionado, así que el plan gana una auditoría propia que lo contradice donde toca. - **§15 — la invariante, cerrada y medida.** Incluye lo que la medición DESMINTIÓ: el handoff anterior predecía un aviso por consola en `/temas/sema` que no podía aparecer, porque el aviso cuenta contextos VIVOS y allí sólo había uno. Y **E-8**, el agujero de attach que E-1 dio por cableado sin estarlo. - **§16 — 5 hallazgos ABIERTOS sobre el art**, tres con decisión pendiente: `analyze.ts` sigue abriendo su contexto (medido 1 → 2 al importar un `.wav`); el aviso de segundo contexto es ciego a los contextos crudos; **S-1 NO está cerrado** y §7 afirmaba lo contrario —queda tachado en su sitio—; el canal tira en silencio `masterGain` y compañía cuando le inyectan el motor; y `gainScale`, que es de UNA reproducción, se escribe en el master compartido. - **§16.1 — la pregunta de fondo**: el corte separa módulos, **no conocimiento**. La quinta a 1.5×, el ADSR recortado a 15 %/40 %, el contour de ±400 cents y el mapeo de `roughness` son diseño sonoro calibrado contra el vocabulario de sema, viviendo dentro de lo que el plan llama «maquinaria». El precedente de `$motion` —datos en la capa, mecanismos en el art— no se replica tan de cerca como §3 afirma. Y la superficie no sirve a dos de los tres consumidores que la justificaban: `Waveform` y el analizador necesitan los `AudioBuffer`, y `preload(urls)` los esconde en una caché privada. - **`sema.md` corregido**: «sema no importa el art» era cierto para la INSTANCIA y falso para el grafo de módulos. - **`PLAN-audio-player` con las 5 correcciones de su cabecera incorporadas al cuerpo**, que era la condición para presentar nada: segmentos en la matriz §2 y en el contrato §7.5; **G-2 sube a bloqueante** (buffer, ventana de clip, capítulos); las dos invariantes de §7.6 (`prefs.sound` NUNCA toca el volumen del contenido; el silencio de UI debe alcanzar al `Slider` compuesto). **D-AP.11 escrita contra el art** —su premisa, «no hay motor», era falsa— y **D-AP.12** (segmentos `clip`) añadida; las dos existían como referencia sin fila en §4. **D-AP.7 reescrita** como ducking. - El handoff añade dos reglas que costaron caro: **un guard que no has visto fallar no vale**, y acoplarse al dev server vivo del usuario en vez de levantar otro. D-AP.1…D-AP.12 siguen SIN FIRMAR: el gate es lo primero de F0 y no se escribe una línea de código del player antes. `docs:check` deja 1 error ajeno (`blocks/banner/README.md:15`, «8 roles» por 9). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
❌ **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.
refactor(sound): extraer el motor Web Audio de sema al art `$sound` `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 <noreply@anthropic.com>
2 months ago
## 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) |
docs(process): el motor cierra sus fases y abre 5 hallazgos; el player, al gate `PLAN-sound-engine` pasa a CERRADO (F0…F5) y `PLAN-audio-player` a DESBLOQUEADO. Pero cerrar las fases no es dejar el diseño incuestionado, así que el plan gana una auditoría propia que lo contradice donde toca. - **§15 — la invariante, cerrada y medida.** Incluye lo que la medición DESMINTIÓ: el handoff anterior predecía un aviso por consola en `/temas/sema` que no podía aparecer, porque el aviso cuenta contextos VIVOS y allí sólo había uno. Y **E-8**, el agujero de attach que E-1 dio por cableado sin estarlo. - **§16 — 5 hallazgos ABIERTOS sobre el art**, tres con decisión pendiente: `analyze.ts` sigue abriendo su contexto (medido 1 → 2 al importar un `.wav`); el aviso de segundo contexto es ciego a los contextos crudos; **S-1 NO está cerrado** y §7 afirmaba lo contrario —queda tachado en su sitio—; el canal tira en silencio `masterGain` y compañía cuando le inyectan el motor; y `gainScale`, que es de UNA reproducción, se escribe en el master compartido. - **§16.1 — la pregunta de fondo**: el corte separa módulos, **no conocimiento**. La quinta a 1.5×, el ADSR recortado a 15 %/40 %, el contour de ±400 cents y el mapeo de `roughness` son diseño sonoro calibrado contra el vocabulario de sema, viviendo dentro de lo que el plan llama «maquinaria». El precedente de `$motion` —datos en la capa, mecanismos en el art— no se replica tan de cerca como §3 afirma. Y la superficie no sirve a dos de los tres consumidores que la justificaban: `Waveform` y el analizador necesitan los `AudioBuffer`, y `preload(urls)` los esconde en una caché privada. - **`sema.md` corregido**: «sema no importa el art» era cierto para la INSTANCIA y falso para el grafo de módulos. - **`PLAN-audio-player` con las 5 correcciones de su cabecera incorporadas al cuerpo**, que era la condición para presentar nada: segmentos en la matriz §2 y en el contrato §7.5; **G-2 sube a bloqueante** (buffer, ventana de clip, capítulos); las dos invariantes de §7.6 (`prefs.sound` NUNCA toca el volumen del contenido; el silencio de UI debe alcanzar al `Slider` compuesto). **D-AP.11 escrita contra el art** —su premisa, «no hay motor», era falsa— y **D-AP.12** (segmentos `clip`) añadida; las dos existían como referencia sin fila en §4. **D-AP.7 reescrita** como ducking. - El handoff añade dos reglas que costaron caro: **un guard que no has visto fallar no vale**, y acoplarse al dev server vivo del usuario en vez de levantar otro. D-AP.1…D-AP.12 siguen SIN FIRMAR: el gate es lo primero de F0 y no se escribe una línea de código del player antes. `docs:check` deja 1 error ajeno (`blocks/banner/README.md:15`, «8 roles» por 9). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
| **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). |
refactor(sound): extraer el motor Web Audio de sema al art `$sound` `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 <noreply@anthropic.com>
2 months ago
| **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 |
docs(process): el motor cierra sus fases y abre 5 hallazgos; el player, al gate `PLAN-sound-engine` pasa a CERRADO (F0…F5) y `PLAN-audio-player` a DESBLOQUEADO. Pero cerrar las fases no es dejar el diseño incuestionado, así que el plan gana una auditoría propia que lo contradice donde toca. - **§15 — la invariante, cerrada y medida.** Incluye lo que la medición DESMINTIÓ: el handoff anterior predecía un aviso por consola en `/temas/sema` que no podía aparecer, porque el aviso cuenta contextos VIVOS y allí sólo había uno. Y **E-8**, el agujero de attach que E-1 dio por cableado sin estarlo. - **§16 — 5 hallazgos ABIERTOS sobre el art**, tres con decisión pendiente: `analyze.ts` sigue abriendo su contexto (medido 1 → 2 al importar un `.wav`); el aviso de segundo contexto es ciego a los contextos crudos; **S-1 NO está cerrado** y §7 afirmaba lo contrario —queda tachado en su sitio—; el canal tira en silencio `masterGain` y compañía cuando le inyectan el motor; y `gainScale`, que es de UNA reproducción, se escribe en el master compartido. - **§16.1 — la pregunta de fondo**: el corte separa módulos, **no conocimiento**. La quinta a 1.5×, el ADSR recortado a 15 %/40 %, el contour de ±400 cents y el mapeo de `roughness` son diseño sonoro calibrado contra el vocabulario de sema, viviendo dentro de lo que el plan llama «maquinaria». El precedente de `$motion` —datos en la capa, mecanismos en el art— no se replica tan de cerca como §3 afirma. Y la superficie no sirve a dos de los tres consumidores que la justificaban: `Waveform` y el analizador necesitan los `AudioBuffer`, y `preload(urls)` los esconde en una caché privada. - **`sema.md` corregido**: «sema no importa el art» era cierto para la INSTANCIA y falso para el grafo de módulos. - **`PLAN-audio-player` con las 5 correcciones de su cabecera incorporadas al cuerpo**, que era la condición para presentar nada: segmentos en la matriz §2 y en el contrato §7.5; **G-2 sube a bloqueante** (buffer, ventana de clip, capítulos); las dos invariantes de §7.6 (`prefs.sound` NUNCA toca el volumen del contenido; el silencio de UI debe alcanzar al `Slider` compuesto). **D-AP.11 escrita contra el art** —su premisa, «no hay motor», era falsa— y **D-AP.12** (segmentos `clip`) añadida; las dos existían como referencia sin fila en §4. **D-AP.7 reescrita** como ducking. - El handoff añade dos reglas que costaron caro: **un guard que no has visto fallar no vale**, y acoplarse al dev server vivo del usuario en vez de levantar otro. D-AP.1…D-AP.12 siguen SIN FIRMAR: el gate es lo primero de F0 y no se escribe una línea de código del player antes. `docs:check` deja 1 error ajeno (`blocks/banner/README.md:15`, «8 roles» por 9). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
| **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 |
refactor(sound): extraer el motor Web Audio de sema al art `$sound` `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 <noreply@anthropic.com>
2 months ago
**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ó.
docs(process): el motor cierra sus fases y abre 5 hallazgos; el player, al gate `PLAN-sound-engine` pasa a CERRADO (F0…F5) y `PLAN-audio-player` a DESBLOQUEADO. Pero cerrar las fases no es dejar el diseño incuestionado, así que el plan gana una auditoría propia que lo contradice donde toca. - **§15 — la invariante, cerrada y medida.** Incluye lo que la medición DESMINTIÓ: el handoff anterior predecía un aviso por consola en `/temas/sema` que no podía aparecer, porque el aviso cuenta contextos VIVOS y allí sólo había uno. Y **E-8**, el agujero de attach que E-1 dio por cableado sin estarlo. - **§16 — 5 hallazgos ABIERTOS sobre el art**, tres con decisión pendiente: `analyze.ts` sigue abriendo su contexto (medido 1 → 2 al importar un `.wav`); el aviso de segundo contexto es ciego a los contextos crudos; **S-1 NO está cerrado** y §7 afirmaba lo contrario —queda tachado en su sitio—; el canal tira en silencio `masterGain` y compañía cuando le inyectan el motor; y `gainScale`, que es de UNA reproducción, se escribe en el master compartido. - **§16.1 — la pregunta de fondo**: el corte separa módulos, **no conocimiento**. La quinta a 1.5×, el ADSR recortado a 15 %/40 %, el contour de ±400 cents y el mapeo de `roughness` son diseño sonoro calibrado contra el vocabulario de sema, viviendo dentro de lo que el plan llama «maquinaria». El precedente de `$motion` —datos en la capa, mecanismos en el art— no se replica tan de cerca como §3 afirma. Y la superficie no sirve a dos de los tres consumidores que la justificaban: `Waveform` y el analizador necesitan los `AudioBuffer`, y `preload(urls)` los esconde en una caché privada. - **`sema.md` corregido**: «sema no importa el art» era cierto para la INSTANCIA y falso para el grafo de módulos. - **`PLAN-audio-player` con las 5 correcciones de su cabecera incorporadas al cuerpo**, que era la condición para presentar nada: segmentos en la matriz §2 y en el contrato §7.5; **G-2 sube a bloqueante** (buffer, ventana de clip, capítulos); las dos invariantes de §7.6 (`prefs.sound` NUNCA toca el volumen del contenido; el silencio de UI debe alcanzar al `Slider` compuesto). **D-AP.11 escrita contra el art** —su premisa, «no hay motor», era falsa— y **D-AP.12** (segmentos `clip`) añadida; las dos existían como referencia sin fila en §4. **D-AP.7 reescrita** como ducking. - El handoff añade dos reglas que costaron caro: **un guard que no has visto fallar no vale**, y acoplarse al dev server vivo del usuario en vez de levantar otro. D-AP.1…D-AP.12 siguen SIN FIRMAR: el gate es lo primero de F0 y no se escribe una línea de código del player antes. `docs:check` deja 1 error ajeno (`blocks/banner/README.md:15`, «8 roles» por 9). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
## 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 |
| --- | --- | --- |
fix(sound): `decode()` — la puerta que faltaba, y el estudio deja de abrir su contexto A-1 de la auditoría (`PLAN-sound-engine` §16): `analyze.ts` hacía `new Ctor()` propio para decodificar el `.wav` importado. Medido inyectando un WAV real por el `input[type=file]`: **1 → 2 contextos**. Transitorio —se cerraba al acabar el decode— pero es exactamente el antipatrón que el art existe para impedir, en la misma página que usé como prueba de la invariante. Y el aviso de «segundo contexto» no lo vio, porque sólo cuenta los contextos que crea el art (A-2). No era un despiste de esa página: **la superficie del art no ofrecía la operación**. `preload(urls)` decodifica y esconde los `AudioBuffer` en una caché privada, así que quien quiere las MUESTRAS —un waveform leyendo picos, un analizador midiendo un fichero— no tenía puerta y se abría la suya. - **`EngineSound.decode(data): Promise<AudioBuffer | null>`**. Segunda puerta, simétrica de `context`: una para quien quiere un GRAFO, otra para quien quiere las MUESTRAS. Las dos llevan al mismo contexto. - **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ñade `ensureContext()`, crear sin resumir. - **No cachea** (los bytes crudos no tienen clave; la caché por URL es de `preload`) y **devuelve `null` en vez de lanzar**. - **Desviación declarada** de lo que §16.2 proponía: NO se añade el acceso a los buffers cacheados. Hoy no tiene consumidor —`Waveform` no existe— y añadir superficie sin consumidor es justo lo que §16.1 le reprocha al art. - `analyze.ts` toma el motor por puerto estructural; `AnalyzePanel` lo lee de `getActiveUix()`, que es el idioma de la casa, no prop-drilling. MEDIDO en Chrome: importar un WAV crea ahora **1 contexto** en vez de 2, y el earcon posterior reutiliza ése. Y sigue analizando bien: un tono de 440 Hz a 8 kHz, decodificado sobre el contexto compartido a 44,1 kHz, se mide como **441 Hz** — el resampleo no falsea la medida. Guards nuevos en `engine-sound.test.ts`: decodifica sobre el contexto compartido SIN llamar a `resume()`, un segundo `decode` no crea otro contexto, y devuelve `null` con bytes indecodificables o sin contexto. Verificación: art + sema 17 suites / 201 tests · `check` en la baseline exacta (73 errores, 0 propios). Quedan abiertos A-2 (el aviso es ciego a los contextos crudos), A-3 (S-1 no está cerrado), A-4 (opciones del canal tiradas en silencio) y A-5 (`gainScale` en el master compartido) — los tres últimos cambian comportamiento público y esperan decisión. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
| **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) |
fix(sound): la auditoría del art, cerrada — niveles por reproducción y opciones que no mienten Los tres hallazgos que quedaban de `PLAN-sound-engine` §16, firmados y aplicados. Dos con código, uno con una medición que cambia la doctrina. **A-5 — `gainScale` es de la reproducción, no del documento.** `play()` escribía `master × gainScale` en el nodo master, del que cuelgan TODOS los earcons. Un parámetro transitorio corrompía un mando de política: el nivel se filtraba entre reproducciones, una nota en vuelo saltaba de volumen, y los dos consumidores que motivan el motor compartido —la reducción de sema y el ducking de un reproductor— se pisaban sobre un único valor. Ahora el factor multiplica el pico de la envolvente de ESA reproducción, y **también la profundidad del AM**: dejarla absoluta habría hecho que un earcon atenuado sonara relativamente más áspero, cambiando 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. Lo que pineaba —«la ganancia se fija ANTES de que la síntesis reviente»— sólo importaba porque el valor iba a un nodo compartido. La cobertura no se pierde, se coloca donde vive cada responsabilidad: `sound-port.test.ts` ya pineaba que el canal resuelve el NIVEL y lo entrega; `sound.test.ts` pinea ahora que el master **no se mueve**; y el art estrena un contexto falso que sí construye el grafo, para ver el pico de la envolvente. **A-4 — las opciones del canal, imposibles de equivocar.** `SoundChannelOptions` pasa a unión discriminada: `{ engine, preferences?, logger? }` **o** la forma de construcción (`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, y hasta hoy se aceptaban y se tiraban en silencio. La defensa primaria es el tipo (doctrina de la casa), con un guard `@ts-expect-error` que hace fallar `check` el día que la unión deje de rechazar la mezcla; el aviso por logger es la red para JS. `engine.ts` pasa de un spread a tres ramas en orden de precedencia. Y **`fetchFn` → `fetcher`**, para alinearse con `$perm`: un nombre por concepto. `audioContextFactory` **se queda** — es el patrón `idFactory` que ya usan `$logger` y `$bus`, y nombra el tipo exacto que fabrica (`context` a secas colisiona con el contexto GL de `$scene`). **A-3 — S-1 aceptado, medido: 2,2 KB.** El art entero son 6.410 bytes minificados / 2.177 gzip. Y mi propia propuesta para cerrarlo era falsa: quitar el fallback del canal no saca el art de ningún bundle, porque `createActiveUix` y `defineEngineSound` lo importan incondicionalmente. Cerrarlo de verdad exige carga diferida, que choca con que `prime()` deba ser síncrono dentro del gesto, y volver `uix.sound` perezoso — cambio de superficie pública por 2,2 KB. Se acepta: el art es servicio de núcleo, como `motion` y `scene`. Queda corregida la doctrina: S-1 era el más débil de los cuatro síntomas; los que justificaban la extracción eran S-2 y S-4. **La voz, declarada como diseño.** La quinta a 1.5×, el ADSR recortado a 15 %/40 %, el contour de ±400 cents y el mapeo del AM son decisiones perceptivas calibradas contra el vocabulario de sema, no maquinaria. Escrito en el README del art y en la cabecera de `engine-sound.ts`, con la regla: el día que un segundo consumidor quiera otra voz, ése es el momento de partir el art en dos —gobierno de contexto / voz—, no antes. Verificación: art + sema **17 suites / 204 tests** · `contracts.test.ts` 35/38 (los 3 rojos, ajenos) · `check` en la baseline exacta (73, 0 propios) · `docs:check` **0 errores** · los guards de A-5 **vistos fallar** al reintroducir la escritura al master · navegador `/temas/sema`, 4 ciclos de editar→disparar: **1 contexto, 16 osciladores, master en 1, cero errores de consola**. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
| **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) |
docs(process): el motor cierra sus fases y abre 5 hallazgos; el player, al gate `PLAN-sound-engine` pasa a CERRADO (F0…F5) y `PLAN-audio-player` a DESBLOQUEADO. Pero cerrar las fases no es dejar el diseño incuestionado, así que el plan gana una auditoría propia que lo contradice donde toca. - **§15 — la invariante, cerrada y medida.** Incluye lo que la medición DESMINTIÓ: el handoff anterior predecía un aviso por consola en `/temas/sema` que no podía aparecer, porque el aviso cuenta contextos VIVOS y allí sólo había uno. Y **E-8**, el agujero de attach que E-1 dio por cableado sin estarlo. - **§16 — 5 hallazgos ABIERTOS sobre el art**, tres con decisión pendiente: `analyze.ts` sigue abriendo su contexto (medido 1 → 2 al importar un `.wav`); el aviso de segundo contexto es ciego a los contextos crudos; **S-1 NO está cerrado** y §7 afirmaba lo contrario —queda tachado en su sitio—; el canal tira en silencio `masterGain` y compañía cuando le inyectan el motor; y `gainScale`, que es de UNA reproducción, se escribe en el master compartido. - **§16.1 — la pregunta de fondo**: el corte separa módulos, **no conocimiento**. La quinta a 1.5×, el ADSR recortado a 15 %/40 %, el contour de ±400 cents y el mapeo de `roughness` son diseño sonoro calibrado contra el vocabulario de sema, viviendo dentro de lo que el plan llama «maquinaria». El precedente de `$motion` —datos en la capa, mecanismos en el art— no se replica tan de cerca como §3 afirma. Y la superficie no sirve a dos de los tres consumidores que la justificaban: `Waveform` y el analizador necesitan los `AudioBuffer`, y `preload(urls)` los esconde en una caché privada. - **`sema.md` corregido**: «sema no importa el art» era cierto para la INSTANCIA y falso para el grafo de módulos. - **`PLAN-audio-player` con las 5 correcciones de su cabecera incorporadas al cuerpo**, que era la condición para presentar nada: segmentos en la matriz §2 y en el contrato §7.5; **G-2 sube a bloqueante** (buffer, ventana de clip, capítulos); las dos invariantes de §7.6 (`prefs.sound` NUNCA toca el volumen del contenido; el silencio de UI debe alcanzar al `Slider` compuesto). **D-AP.11 escrita contra el art** —su premisa, «no hay motor», era falsa— y **D-AP.12** (segmentos `clip`) añadida; las dos existían como referencia sin fila en §4. **D-AP.7 reescrita** como ducking. - El handoff añade dos reglas que costaron caro: **un guard que no has visto fallar no vale**, y acoplarse al dev server vivo del usuario en vez de levantar otro. D-AP.1…D-AP.12 siguen SIN FIRMAR: el gate es lo primero de F0 y no se escribe una línea de código del player antes. `docs:check` deja 1 error ajeno (`blocks/banner/README.md:15`, «8 roles» por 9). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
### 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
fix(sound): la auditoría del art, cerrada — niveles por reproducción y opciones que no mienten Los tres hallazgos que quedaban de `PLAN-sound-engine` §16, firmados y aplicados. Dos con código, uno con una medición que cambia la doctrina. **A-5 — `gainScale` es de la reproducción, no del documento.** `play()` escribía `master × gainScale` en el nodo master, del que cuelgan TODOS los earcons. Un parámetro transitorio corrompía un mando de política: el nivel se filtraba entre reproducciones, una nota en vuelo saltaba de volumen, y los dos consumidores que motivan el motor compartido —la reducción de sema y el ducking de un reproductor— se pisaban sobre un único valor. Ahora el factor multiplica el pico de la envolvente de ESA reproducción, y **también la profundidad del AM**: dejarla absoluta habría hecho que un earcon atenuado sonara relativamente más áspero, cambiando 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. Lo que pineaba —«la ganancia se fija ANTES de que la síntesis reviente»— sólo importaba porque el valor iba a un nodo compartido. La cobertura no se pierde, se coloca donde vive cada responsabilidad: `sound-port.test.ts` ya pineaba que el canal resuelve el NIVEL y lo entrega; `sound.test.ts` pinea ahora que el master **no se mueve**; y el art estrena un contexto falso que sí construye el grafo, para ver el pico de la envolvente. **A-4 — las opciones del canal, imposibles de equivocar.** `SoundChannelOptions` pasa a unión discriminada: `{ engine, preferences?, logger? }` **o** la forma de construcción (`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, y hasta hoy se aceptaban y se tiraban en silencio. La defensa primaria es el tipo (doctrina de la casa), con un guard `@ts-expect-error` que hace fallar `check` el día que la unión deje de rechazar la mezcla; el aviso por logger es la red para JS. `engine.ts` pasa de un spread a tres ramas en orden de precedencia. Y **`fetchFn` → `fetcher`**, para alinearse con `$perm`: un nombre por concepto. `audioContextFactory` **se queda** — es el patrón `idFactory` que ya usan `$logger` y `$bus`, y nombra el tipo exacto que fabrica (`context` a secas colisiona con el contexto GL de `$scene`). **A-3 — S-1 aceptado, medido: 2,2 KB.** El art entero son 6.410 bytes minificados / 2.177 gzip. Y mi propia propuesta para cerrarlo era falsa: quitar el fallback del canal no saca el art de ningún bundle, porque `createActiveUix` y `defineEngineSound` lo importan incondicionalmente. Cerrarlo de verdad exige carga diferida, que choca con que `prime()` deba ser síncrono dentro del gesto, y volver `uix.sound` perezoso — cambio de superficie pública por 2,2 KB. Se acepta: el art es servicio de núcleo, como `motion` y `scene`. Queda corregida la doctrina: S-1 era el más débil de los cuatro síntomas; los que justificaban la extracción eran S-2 y S-4. **La voz, declarada como diseño.** La quinta a 1.5×, el ADSR recortado a 15 %/40 %, el contour de ±400 cents y el mapeo del AM son decisiones perceptivas calibradas contra el vocabulario de sema, no maquinaria. Escrito en el README del art y en la cabecera de `engine-sound.ts`, con la regla: el día que un segundo consumidor quiera otra voz, ése es el momento de partir el art en dos —gobierno de contexto / voz—, no antes. Verificación: art + sema **17 suites / 204 tests** · `contracts.test.ts` 35/38 (los 3 rojos, ajenos) · `check` en la baseline exacta (73, 0 propios) · `docs:check` **0 errores** · los guards de A-5 **vistos fallar** al reintroducir la escritura al master · navegador `/temas/sema`, 4 ciclos de editar→disparar: **1 contexto, 16 osciladores, master en 1, cero errores de consola**. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
> **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.
docs(process): el motor cierra sus fases y abre 5 hallazgos; el player, al gate `PLAN-sound-engine` pasa a CERRADO (F0…F5) y `PLAN-audio-player` a DESBLOQUEADO. Pero cerrar las fases no es dejar el diseño incuestionado, así que el plan gana una auditoría propia que lo contradice donde toca. - **§15 — la invariante, cerrada y medida.** Incluye lo que la medición DESMINTIÓ: el handoff anterior predecía un aviso por consola en `/temas/sema` que no podía aparecer, porque el aviso cuenta contextos VIVOS y allí sólo había uno. Y **E-8**, el agujero de attach que E-1 dio por cableado sin estarlo. - **§16 — 5 hallazgos ABIERTOS sobre el art**, tres con decisión pendiente: `analyze.ts` sigue abriendo su contexto (medido 1 → 2 al importar un `.wav`); el aviso de segundo contexto es ciego a los contextos crudos; **S-1 NO está cerrado** y §7 afirmaba lo contrario —queda tachado en su sitio—; el canal tira en silencio `masterGain` y compañía cuando le inyectan el motor; y `gainScale`, que es de UNA reproducción, se escribe en el master compartido. - **§16.1 — la pregunta de fondo**: el corte separa módulos, **no conocimiento**. La quinta a 1.5×, el ADSR recortado a 15 %/40 %, el contour de ±400 cents y el mapeo de `roughness` son diseño sonoro calibrado contra el vocabulario de sema, viviendo dentro de lo que el plan llama «maquinaria». El precedente de `$motion` —datos en la capa, mecanismos en el art— no se replica tan de cerca como §3 afirma. Y la superficie no sirve a dos de los tres consumidores que la justificaban: `Waveform` y el analizador necesitan los `AudioBuffer`, y `preload(urls)` los esconde en una caché privada. - **`sema.md` corregido**: «sema no importa el art» era cierto para la INSTANCIA y falso para el grafo de módulos. - **`PLAN-audio-player` con las 5 correcciones de su cabecera incorporadas al cuerpo**, que era la condición para presentar nada: segmentos en la matriz §2 y en el contrato §7.5; **G-2 sube a bloqueante** (buffer, ventana de clip, capítulos); las dos invariantes de §7.6 (`prefs.sound` NUNCA toca el volumen del contenido; el silencio de UI debe alcanzar al `Slider` compuesto). **D-AP.11 escrita contra el art** —su premisa, «no hay motor», era falsa— y **D-AP.12** (segmentos `clip`) añadida; las dos existían como referencia sin fila en §4. **D-AP.7 reescrita** como ducking. - El handoff añade dos reglas que costaron caro: **un guard que no has visto fallar no vale**, y acoplarse al dev server vivo del usuario en vez de levantar otro. D-AP.1…D-AP.12 siguen SIN FIRMAR: el gate es lo primero de F0 y no se escribe una línea de código del player antes. `docs:check` deja 1 error ajeno (`blocks/banner/README.md:15`, «8 roles» por 9). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
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.
fix(sound): `decode()` — la puerta que faltaba, y el estudio deja de abrir su contexto A-1 de la auditoría (`PLAN-sound-engine` §16): `analyze.ts` hacía `new Ctor()` propio para decodificar el `.wav` importado. Medido inyectando un WAV real por el `input[type=file]`: **1 → 2 contextos**. Transitorio —se cerraba al acabar el decode— pero es exactamente el antipatrón que el art existe para impedir, en la misma página que usé como prueba de la invariante. Y el aviso de «segundo contexto» no lo vio, porque sólo cuenta los contextos que crea el art (A-2). No era un despiste de esa página: **la superficie del art no ofrecía la operación**. `preload(urls)` decodifica y esconde los `AudioBuffer` en una caché privada, así que quien quiere las MUESTRAS —un waveform leyendo picos, un analizador midiendo un fichero— no tenía puerta y se abría la suya. - **`EngineSound.decode(data): Promise<AudioBuffer | null>`**. Segunda puerta, simétrica de `context`: una para quien quiere un GRAFO, otra para quien quiere las MUESTRAS. Las dos llevan al mismo contexto. - **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ñade `ensureContext()`, crear sin resumir. - **No cachea** (los bytes crudos no tienen clave; la caché por URL es de `preload`) y **devuelve `null` en vez de lanzar**. - **Desviación declarada** de lo que §16.2 proponía: NO se añade el acceso a los buffers cacheados. Hoy no tiene consumidor —`Waveform` no existe— y añadir superficie sin consumidor es justo lo que §16.1 le reprocha al art. - `analyze.ts` toma el motor por puerto estructural; `AnalyzePanel` lo lee de `getActiveUix()`, que es el idioma de la casa, no prop-drilling. MEDIDO en Chrome: importar un WAV crea ahora **1 contexto** en vez de 2, y el earcon posterior reutiliza ése. Y sigue analizando bien: un tono de 440 Hz a 8 kHz, decodificado sobre el contexto compartido a 44,1 kHz, se mide como **441 Hz** — el resampleo no falsea la medida. Guards nuevos en `engine-sound.test.ts`: decodifica sobre el contexto compartido SIN llamar a `resume()`, un segundo `decode` no crea otro contexto, y devuelve `null` con bytes indecodificables o sin contexto. Verificación: art + sema 17 suites / 201 tests · `check` en la baseline exacta (73 errores, 0 propios). Quedan abiertos A-2 (el aviso es ciego a los contextos crudos), A-3 (S-1 no está cerrado), A-4 (opciones del canal tiradas en silencio) y A-5 (`gainScale` en el master compartido) — los tres últimos cambian comportamiento público y esperan decisión. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
### 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).
fix(sound): la auditoría del art, cerrada — niveles por reproducción y opciones que no mienten Los tres hallazgos que quedaban de `PLAN-sound-engine` §16, firmados y aplicados. Dos con código, uno con una medición que cambia la doctrina. **A-5 — `gainScale` es de la reproducción, no del documento.** `play()` escribía `master × gainScale` en el nodo master, del que cuelgan TODOS los earcons. Un parámetro transitorio corrompía un mando de política: el nivel se filtraba entre reproducciones, una nota en vuelo saltaba de volumen, y los dos consumidores que motivan el motor compartido —la reducción de sema y el ducking de un reproductor— se pisaban sobre un único valor. Ahora el factor multiplica el pico de la envolvente de ESA reproducción, y **también la profundidad del AM**: dejarla absoluta habría hecho que un earcon atenuado sonara relativamente más áspero, cambiando 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. Lo que pineaba —«la ganancia se fija ANTES de que la síntesis reviente»— sólo importaba porque el valor iba a un nodo compartido. La cobertura no se pierde, se coloca donde vive cada responsabilidad: `sound-port.test.ts` ya pineaba que el canal resuelve el NIVEL y lo entrega; `sound.test.ts` pinea ahora que el master **no se mueve**; y el art estrena un contexto falso que sí construye el grafo, para ver el pico de la envolvente. **A-4 — las opciones del canal, imposibles de equivocar.** `SoundChannelOptions` pasa a unión discriminada: `{ engine, preferences?, logger? }` **o** la forma de construcción (`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, y hasta hoy se aceptaban y se tiraban en silencio. La defensa primaria es el tipo (doctrina de la casa), con un guard `@ts-expect-error` que hace fallar `check` el día que la unión deje de rechazar la mezcla; el aviso por logger es la red para JS. `engine.ts` pasa de un spread a tres ramas en orden de precedencia. Y **`fetchFn` → `fetcher`**, para alinearse con `$perm`: un nombre por concepto. `audioContextFactory` **se queda** — es el patrón `idFactory` que ya usan `$logger` y `$bus`, y nombra el tipo exacto que fabrica (`context` a secas colisiona con el contexto GL de `$scene`). **A-3 — S-1 aceptado, medido: 2,2 KB.** El art entero son 6.410 bytes minificados / 2.177 gzip. Y mi propia propuesta para cerrarlo era falsa: quitar el fallback del canal no saca el art de ningún bundle, porque `createActiveUix` y `defineEngineSound` lo importan incondicionalmente. Cerrarlo de verdad exige carga diferida, que choca con que `prime()` deba ser síncrono dentro del gesto, y volver `uix.sound` perezoso — cambio de superficie pública por 2,2 KB. Se acepta: el art es servicio de núcleo, como `motion` y `scene`. Queda corregida la doctrina: S-1 era el más débil de los cuatro síntomas; los que justificaban la extracción eran S-2 y S-4. **La voz, declarada como diseño.** La quinta a 1.5×, el ADSR recortado a 15 %/40 %, el contour de ±400 cents y el mapeo del AM son decisiones perceptivas calibradas contra el vocabulario de sema, no maquinaria. Escrito en el README del art y en la cabecera de `engine-sound.ts`, con la regla: el día que un segundo consumidor quiera otra voz, ése es el momento de partir el art en dos —gobierno de contexto / voz—, no antes. Verificación: art + sema **17 suites / 204 tests** · `contracts.test.ts` 35/38 (los 3 rojos, ajenos) · `check` en la baseline exacta (73, 0 propios) · `docs:check` **0 errores** · los guards de A-5 **vistos fallar** al reintroducir la escritura al master · navegador `/temas/sema`, 4 ciclos de editar→disparar: **1 contexto, 16 osciladores, master en 1, cero errores de consola**. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 months ago
### 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.