37 KiB
PLAN — $sound / EngineSound: extraer el motor de audio a un art
Tipo: plan de ejecución por fases (process — efímero, NO fuente de verdad). Fecha: 2026-07-30 · Estado: CERRADO — D-SND.1…D-SND.6 firmadas · F0 ✅ F1 ✅ F2 ✅ F3 ✅ F4 ✅ F5 ✅. Cierre:
$soundexiste, sema lo consume por puerto sin importarlo,uix.soundestá en la superficie pública y en la tabla ejecutable de contratos, y la invariante de un soloAudioContextpor documento está cerrada en los DOS modos de arranque y medida en Chrome (§15). ⚠️ Fases cerradas ≠ diseño incuestionado: una auditoría posterior deja 5 hallazgos abiertos sobre el propio art (§16) — entre ellos que S-1 no está cerrado pese a lo que dice §7, y que la superficie no sirve a dos de los tres consumidores que la justificaban. Tres necesitan decisión del usuario. 18 suites / 225 tests verdes enactive-uix+sema+ el art ·checken la baseline exacta (73, 0 propios). Kickoff para sesión nueva: "Leedocs/process/PLAN-audio-player.md" — esta iniciativa ya no tiene trabajo pendiente.Revisión adversarial (2026-07-30): el plan pasó una revisión externa que levantó 9 hallazgos, todos verificados contra el código y todos incorporados como enmiendas E-1…E-7 (§14). Los tres de severidad alta: el cableado de propiedad del engine no estaba escrito (E-1), faltaba la fila en la tabla ejecutable de contratos (E-2), y el auto-suspend de F3 saboteaba al reproductor —el consumidor que motiva el art— (E-3).
Origen: análisis del reproductor de sonido (sesión 2026-07-30). Al buscar dónde vivía el motor de audio del ecosistema apareció que ya existe uno, sin nombre de motor y en la carpeta equivocada: 407 líneas de Web Audio dentro de
sema/chans/sound.ts. Decisión del usuario: aparcar el reproductor y extraer el motor primero. La iniciativa del player queda bloqueada por ésta (PLAN-audio-player.md).Antes de escribir código: firmar §11. Lo de aquí son propuestas razonadas.
1. La deuda, medida
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 |
| 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 |
| 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 vs 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 |
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 comouix.motiony 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
| 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
(posicionamiento, ambas capas) y $scene
(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 |
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, 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)
- 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. - Constructor sin efectos (RC-1) — deja de ser preferencia y pasa a requisito verificado.
masterGaines opción de construcción, no solo setter. El plan proponía únicamentesetMasterGain(); RC-2 exige el valor puesto en el nodo justo después deprime(). → las dos cosas.- 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. prime()es síncrono (RC-2 no haceawait): crea contexto + gain + listener y lanzaresume()sin esperarlo.- Solo el art registra el listener de unlock — RC-2 exige
getDocument()exactamente 1×; si el canal también lo llamara, serían 2. - 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/ownsSceneenactive-uix.svelte.ts:183,188con suif (owns…) …dispose()en:559-560. F2 solo escribeownsSounden 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
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
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:
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$soundsatisface. - Sema no crea servicios: los crea
ActiveUix/ActiveApp— regla 1 de ownership (active-uix.md). - Sigue degradando: sin engine no hay sonido; sema sigue siendo ornamental y
su contrato mínimo (
soundopcional) no cambia. SoundChanneladelgaza a ~110 líneas: resolver nivel →engine.play(sig). Y el import estático deja de arrastrar el sintetizador — cierra S-1. ❌ Falso, corregido en §16 (A-3):chans/sound.tsimportacreateEngineSoundcomo VALOR para el fallback, así que la cadenaengine.ts → chans/sound.ts → $soundsigue siendo estática. S-1 sigue abierto.
8. El háptico NO se extrae — la asimetría es correcta
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 |
Waveform |
decodeAudioData + cache para picos |
diferido |
| Visualizador | AnalyserNode → efecto de $scene (los dos arts componen) |
diferido |
proof-of-human |
reto de audio (registrado como v2) | componente existe |
Aura |
la voz de la familia delegate |
reservado |
chat-* |
notas de voz | familia existe |
Y el pago que ninguna referencia da: con un motor dueño del earcon y del contenido, el ducking (bajar la UI mientras suena el contenido) pasa de parche de cascada a capacidad del motor.
10. Lo que el art NO se lleva
Audio espacial · cadenas de efectos/DSP · mezclador multipista · grabación ·
MIDI. El art es un contexto bien gobernado + síntesis de earcon + reproducción
de sample. Todo lo demás cuelga de sound.context, que para eso se expone.
11. Decisiones (gate del usuario — SIN FIRMAR)
| ID | Cuestión | Recomendación |
|---|---|---|
| D-SND.1 | ¿Se crea el art? ¿Con qué nombre? | Sí · $sound / EngineSound (convive con $libs/sound igual que $motion con $libs/motion) |
| D-SND.2 | La línea de corte (§4) | Tal cual, con la prueba objetiva: el art no importa ni un tipo de $uix/sema |
| D-SND.3 | ¿Se extrae también el háptico? | No — asimetría justificada por escrito (§8) |
| D-SND.4 | ¿Cómo lo consume sema? | Puerto estructural inyectado por la raíz. Sema no importa el art, no crea servicios, sigue degradando |
| D-SND.5 | Alcance del v1 del art | Mover primero (value-preserving), arreglar después: F3 cierra suspend-en-oculta, presupuesto y diagnostics. Mover-y-arreglar en el mismo pase es como se rompen los refactors |
| D-SND.6 | ¿uix.sound desde el día 1? |
Sí: defineEngineSound() en $active-app/service-factories (initMode: 'lazy') y uix.sound en ActiveUix, junto a uix.motion / uix.timers — es la superficie que el player y Waveform van a pedir |
12. Fases (tras el gate)
| Fase | Contenido | Verify |
|---|---|---|
| F0 — Firma ✅ HECHA 2026-07-30 | D-SND.1–6 firmadas en la columna recomendada · sound.test.ts leído entero · baseline 5/5 verde · contrato de regresión RC-1…RC-6 derivado y §5 corregida con las 7 restricciones que impone (incl. la regla de propiedad quien crea, dispone, que no estaba en el plan) |
✅ npx vitest run src/uix/sema/chans/sound.test.ts → 5/5 |
| F1 — El art ✅ HECHA 2026-07-30 | src/arts/sound/: types.ts (puertos SoundDom + SoundTimers, SoundSignature propia) · engine-sound.ts (movimiento verbatim de contexto/síntesis/contour/samples + delay propio espejo de semaDelay) · index.ts (re-exports con nombre) · README.md · engine-sound.test.ts · alias $sound en vite.config.ts + svelte.config.js. Movimiento puro: cero cambios de comportamiento, cero imports de $uix/sema |
✅ suite del art 8/8 · ✅ check 0 errores propios (los 73 del repo son preexistentes de la rama: web/routes/alpha, demos/animations, …) · ✅ baseline de sema intacta 5/5 · ⚠️ el alias $sound no se ejercita hasta F2 (nadie lo importa aún) |
F2 — Sema consume ✅ HECHA 2026-07-30 — SoundChannel 407 → 136 líneas · soundEngine en EngineSemanticOptions · uix.sound en ambos modos con ownsSound · fila sound en contracts.ts (+ standaloneCreates / publicSurface) · defineEngineSound() · sound-port.test.ts nuevo (guard de tipos E-6 + la regla de propiedad) |
(E-1) El cableado de propiedad, explícito — ActiveUix crea EngineSound incondicionalmente (RC-1 lo hace gratis: sin contexto hasta prime()), lo expone como uix.sound y se lo pasa a EngineSemantic vía opts.soundEngine; en attach, app.sound ?? createEngineSound(…) + ownsSound, espejo literal de ownsMotion/ownsScene. El canal solo crea el suyo si no se lo inyectan (uso directo y tests — RC-3 sigue verde) · (E-2) fila sound en contracts.ts + su assert en contracts.test.ts · (E-4) resolver el instanceof SoundChannel del preload (engine.ts:296) → 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 × intentsRESULTADO: ✅ 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 — emit → cascada → SoundChannel → $sound → grafo real (2 osciladores + biquad + start()), el caso mudo (channels:['haptic']) y el ciclo dispose/rebuild del estudio ×3. 3/3✅ audible confirmado por el usuario en /temas/sema tras limpiar el entorno.RETIRADA — la primera "verificación" de navegador era vacua. Conté 1 AudioContext en /temas/sema y lo presenté como prueba de la invariante de contexto único. No lo era: esa página monta su propio EngineSemantic (_lib/audition.ts:35-43, sound: true) y el createActiveUix de la página pasa sound: false, así que el contexto contado era el del estudio y uix.sound no había creado ninguno (no tiene efectos). Medí una cosa y afirmé otra. La prueba buena es el test e2e de arriba; la invariante de contexto único sigue sin verificarse en un navegador y necesita una página que enchufe soundEngine (hoy ninguna lo hace — ver la nota de attach en F2). → Cerrado en §15: el estudio ya enchufa uix.sound, y la invariante está medida en Chrome (10 contextos → 1). |
F3 — Ciudadanía ✅ HECHA 2026-07-30 — autoSuspend opt-in (default false, un solo getDocument() para los dos listeners, suspend()/resume() explícitos ganan sobre la política) · el aviso se afinó al ejecutar: no al construir un segundo ENGINE (inocuo — ActiveUix crea uno siempre por diseño) sino al ir vivo un segundo CONTEXTO, que es el anti-patrón real · consts.ts con categoría y mensajes con nombre · sin diagnostics.ts ni errors.ts, y es decisión: espejo de $scene, todo fallo aquí es degradación documentada, no error de programador · 3 tests nuevos (11/11, estables en 3 pasadas) |
Cierre de S-2 y S-3: (E-5) auto-suspend en pestaña oculta = OPT-IN (autoSuspend?: boolean, default false) — suspender es política del que compone, no del motor: el earcon quiere callarse con la pestaña oculta y un podcast NO, y el player cuelga su grafo de este mismo contexto · (E-5) el «presupuesto» se reformula: $scene presupuesta N contextos GL concurrentes, pero este engine ES el singleton → lo útil es avisar (warn) si se crea un segundo EngineSound en el documento, que es justo el anti-patrón que originó el art · diagnostics.ts + errors.ts + consts.ts según el contrato de arts (sustituye la desviación 1 de §5.4) |
tests nuevos · check · README del art completo |
| F4 — Docs ✅ HECHA 2026-07-30 | arts/README.md: fila del mapa + alias $sound + entrada en el grafo de dependencias · architecture/sema.md: la sección SoundChannel reescrita («doctrina aquí, máquina en $sound») + el árbol de ficheros · README del art ampliado con autoSuspend y el aviso de segundo contexto. docs/README.md NO tocado: el art se indexa desde arts/README.md, que es lo que E5 prescribe para artefactos |
⚠️ docs:check 1 error, ajeno y preexistente: eidos/components/callout/README.md:23 dice «8 roles» y COLOR_ROLES.length es 9. Fichero commiteado, sin relación con sonido — no lo toco, queda reportado |
| F5 — Desbloqueo ✅ HECHA 2026-07-30 | PLAN-audio-player.md marcado DESBLOQUEADO; las 5 correcciones de su cabecera incorporadas al cuerpo; D-AP.11 escrita contra el art (su premisa —«no hay motor»— era falsa) y D-AP.12 (segmentos) añadida — las dos existían como referencia sin fila en §4 |
El plan del player queda listo para su gate de firma (D-AP.1…D-AP.12). Cero código del player en esta sesión |
Método: no-cascada — una pieza, verificar, la siguiente
(_cierre.md). 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, 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) 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:
- La «regla de propiedad NUEVA» de F0 no era nueva.
ownsMotion/ownsScene(active-uix.svelte.ts:183,188,559-560) ya es el idioma del repo. F2 lo copia; no inventa. - 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 trasdispose(); el art se queda inerte, y su propio test pinea el comportamiento nuevo. Ningún test lo observaba — por eso pasó.
15. La invariante, cerrada y MEDIDA (2026-07-30, sesión de cierre)
F2 dejó dos deudas escritas: la invariante de contexto único sin verificar en
navegador, y ninguna página que enchufase soundEngine. Las dos quedan
cerradas aquí, y por el camino apareció un tercer agujero que nadie había visto.
15.1 Lo que la medición desmintió
El handoff anterior predecía que abrir /temas/sema avisaría por consola del
segundo contexto. No avisa, y no debía: el aviso cuenta contextos VIVOS, y en
esa página sólo había uno — el privado del estudio. uix.sound no abría ninguno
porque nada lo primaba (events: { sound: false }), que es exactamente lo que la
retirada de F2 ya había reconocido. Predicción incorrecta, comprobada antes de
tocar nada.
15.2 Lo que sí estaba roto — el trasiego de contextos
Instrumentando AudioContext en Chrome sobre el servidor del usuario (sin
levantar otro — §13), con 8 ciclos de editar el draft → disparar:
| contextos creados | cerrados | vivos | |
|---|---|---|---|
| Antes (motor privado del canal) | 10 | 9 | 1 |
Después (soundEngine: uix.sound) |
1 | 0 | 1 |
Cada edición del draft reconstruye el EngineSemantic → dispose() del canal →
ctx.close() → el siguiente disparo abre uno nuevo. Además de la invariante, se
perdía la caché de samples decodificados en cada edición (el .wav importado
se volvía a bajar y decodificar). Los osciladores siguen construyéndose en cada
disparo después del arreglo, así que el earcon no se perdió por el camino.
Arreglo: web/routes/temas/sema/_lib/audition.ts
acepta soundEngine y +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.
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
registraba motion y scene junto a dom pero no sound, y
defineEngineSemantic 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.
Verificado que no es vacuo: quitando el reenvío, el test falla
(expected null not to be null) — la lección de la retirada de F2, aplicada.
16. Auditoría posterior al cierre — 5 hallazgos ABIERTOS (2026-07-30)
Al releer lo entregado cuestionando el diseño y no sólo su coherencia con este plan, aparecen cinco cosas. Ninguna invalida lo commiteado; tres necesitan decisión del usuario. Se escriben aquí porque este plan afirma cosas que estos hallazgos corrigen.
| # | Hallazgo | Evidencia |
|---|---|---|
| A-1 | El estudio SIGUE abriendo un segundo contexto. analyze.ts:32 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 | 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 |
| A-3 | 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 · sound.ts:7 |
| A-4 | 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 |
| A-5 | 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 |
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
decode(bytes): Promise<AudioBuffer | null>en el art, y devolver los buffers cacheados. Cierra A-1, es lo queWaveformva a pedir, y convierte la barra de ≥2 en algo real HOY en vez de una promesa.- Mover
gainScaledel master al grafo del earcon (A-5), reescribiendoRC-5— que es justo lo que un contrato de regresión debe permitir cuando lo que pineaba era un defecto. - 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.
- Decidir A-4: que las opciones explícitas ganen al motor inyectado, o que
avisen. Y reservar por escrito el nombre de servicio
sound. - 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.