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

44 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: $sound existe, sema lo consume por puerto sin importarlo, uix.sound está en la superficie pública y en la tabla ejecutable de contratos, y la invariante de un solo AudioContext por documento está cerrada en los DOS modos de arranque y medida en Chrome (§15). Auditoría posterior (§16): 5 hallazgos, los 5 resueltos — A-1 y A-5 con código, A-4 con tipos, A-2 y A-3 declarados por escrito tras medirlos. Lo que deja como doctrina corregida: S-1 nunca fue cerrable por esta vía (son 2,2 KB y el art es servicio de núcleo), y la voz del earcon es diseño, no maquinaria — el corte se trazó en la frontera de módulos, no en la de conocimiento (§16.1). 18 suites / 225 tests verdes en active-uix + sema + el art · check en la baseline exacta (73, 0 propios). Kickoff para sesión nueva: "Lee docs/process/PLAN-audio-player.md" — esta iniciativa ya no tiene trabajo pendiente.

Revisión adversarial (2026-07-30): el plan pasó una revisión externa que levantó 9 hallazgos, todos verificados contra el código y todos incorporados como enmiendas E-1…E-7 (§14). Los tres de severidad alta: el cableado de propiedad del engine no estaba escrito (E-1), faltaba la fila en la tabla ejecutable de contratos (E-2), y el auto-suspend de F3 saboteaba al reproductor —el consumidor que motiva el art— (E-3).

Origen: análisis del reproductor de sonido (sesión 2026-07-30). Al buscar dónde vivía el motor de audio del ecosistema apareció que ya existe uno, sin nombre de motor y en la carpeta equivocada: 407 líneas de Web Audio dentro de sema/chans/sound.ts. Decisión del usuario: aparcar el reproductor y extraer el motor primero. La iniciativa del player queda bloqueada por ésta (PLAN-audio-player.md).

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 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

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)

  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 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

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 $sound satisface.
  • 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 (sound opcional) no cambia.
  • SoundChannel adelgaza a ~110 líneas: resolver nivel → engine.play(sig). Y el import estático deja de arrastrar el sintetizador — cierra S-1. ❌ Falso, corregido en §16 (A-3): chans/sound.ts importa createEngineSound como VALOR para el fallback, así que la cadena engine.ts → chans/sound.ts → $sound sigue siendo estática. S-1 sigue abierto.

8. El háptico NO se extrae — la asimetría es correcta

chans/haptic.ts 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 × intents

RESULTADO: ✅ sema 15/15 suites · 184/184, sin tocar un assert de sound.test.ts · ✅ art 8/8 · ✅ check 73 errores, los mismos que antes de F2, 0 propios · ✅ contracts.test.ts 35/38 — los 3 rojos son preexistentes y ajenos (aura, menubar, radio-group; el fichero ya venía modificado de otra sesión) y el guard que cubre esto —pins ActiveUix public service names— pasa · ✅ camino de audio extremo a extremo: sound-e2e.test.ts — 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:

  1. 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.
  2. F1 tenía una tercera desviación sin declarar que la revisión externa tampoco vio: el flag disposed (§5.4, fila 3). El original recreaba el contexto tras dispose(); el art se queda inerte, y su propio test pinea el comportamiento nuevo. Ningún test lo observaba — por eso pasó.

15. La invariante, cerrada y MEDIDA (2026-07-30, sesión de cierre)

F2 dejó dos deudas escritas: la invariante de contexto único sin verificar en navegador, y ninguna página que enchufase soundEngine. Las dos quedan cerradas aquí, y por el camino apareció un tercer agujero que nadie había visto.

15.1 Lo que la medición desmintió

El handoff anterior predecía que abrir /temas/sema avisaría por consola del segundo contexto. No avisa, y no debía: el aviso cuenta contextos VIVOS, y en esa página sólo había uno — el privado del estudio. uix.sound no abría ninguno porque nada lo primaba (events: { sound: false }), que es exactamente lo que la retirada de F2 ya había reconocido. Predicción incorrecta, comprobada antes de tocar nada.

15.2 Lo que sí estaba roto — el trasiego de contextos

Instrumentando AudioContext en Chrome sobre el servidor del usuario (sin levantar otro — §13), con 8 ciclos de editar el draft → disparar:

contextos creados cerrados vivos
Antes (motor privado del canal) 10 9 1
Después (soundEngine: uix.sound) 1 0 1

Cada edición del draft reconstruye el EngineSemantic → dispose() del canal → ctx.close() → el siguiente disparo abre uno nuevo. Además de la invariante, se perdía la caché de samples decodificados en cada edición (el .wav importado se volvía a bajar y decodificar). Los osciladores siguen construyéndose en cada disparo después del arreglo, así que el earcon no se perdió por el camino.

Arreglo: web/routes/temas/sema/_lib/audition.ts 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 ✅ CERRADO 2026-07-30 (§16.3) 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 ✅ 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
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 · sound.ts:7
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
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

16.1 Y la pregunta de fondo: ¿el corte está bien trazado?

La prueba objetiva de §4 —el art no importa ni un tipo de $uix/sema— se cumple en la frontera de módulos, no en la de conocimiento. Dentro de la «maquinaria» hay cuatro decisiones perceptivas calibradas contra el vocabulario de sema: la quinta a 1.5× mezclada a 0.3, el ADSR recortado a 15 %/40 % («una nota de commit son 100 ms…»), el contour de ±400 cents («±50 era imperceptible a las duraciones de hold típicas») y el mapeo de roughness a profundidad y velocidad de AM. El art no importa sema: sema está horneada dentro del art como constantes.

Compárese con el precedente que §3 invoca: en $motion la capa conservó los DATOS (keyframes, presets, curvas) y el art se llevó MECANISMOS genéricos (drivers). Aquí el mecanismo se llevó también el diseño. El corte de sound no replica el de motion tan de cerca como este plan afirma.

Y la superficie lo confirma: prime/suspend/resume/context/dispose es gobierno de contexto (genérico, con consumidores reales hoy); play(signature) es la voz de la UI (un solo consumidor, y calibrada para él). Son dos artefactos pegados.

Peor: la API no puede servir a los consumidores que la justificaron. Waveform necesita los AudioBuffer para leer picos y preload(urls) los esconde en una caché privada; el analizador del estudio necesita decodificar un File y no hay entrada por bytes — por eso A-1 abre su propio contexto. De los tres consumidores que la barra de ≥2 invocaba, la superficie sólo sirve a uno (el player, vía context).

16.2 Lo propuesto, por orden de valor

Estado: los 5 aplicados (firmados 2026-07-30). El punto 5 de esta lista resultó incorrecto al medirlo — quitar el fallback no cierra S-1, porque las raíces de composición importan el art igual. Corregido en §16.4.

  1. decode(bytes): Promise<AudioBuffer | null> en el art, y devolver los buffers cacheados. Cierra A-1, es lo que Waveform va a pedir, y convierte la barra de ≥2 en algo real HOY en vez de una promesa.
  2. Mover gainScale del master al grafo del earcon (A-5), reescribiendo RC-5 — que es justo lo que un contrato de regresión debe permitir cuando lo que pineaba era un defecto.
  3. Declarar las cuatro constantes como la voz del art, no como maquinaria. Si algún día un segundo consumidor quiere otra voz, ese es el momento de partir el art en dos: gobierno de contexto + voz. No antes.
  4. Decidir A-4: que las opciones explícitas ganen al motor inyectado, o que avisen. Y reservar por escrito el nombre de servicio sound.
  5. A-2 y A-3 se escriben donde prometen de más (README del art, §2, §7) en cuanto se decida 1–4; A-3 se cierra de verdad quitando el fallback del canal o difiriéndolo con import(), que tras §15.3 ya no lo alcanza ninguna raíz de composición.

No se recomienda rehacer la extracción. Fue value-preserving, está verde, y mover la síntesis otra vez no paga el riesgo.

16.3 A-1, cerrado — decode() en el art (2026-07-30)

EngineSound.decode(data: ArrayBuffer): Promise<AudioBuffer | null>: la segunda puerta del art, la que faltaba para los consumidores que quieren las muestras y no un grafo. analyze.ts la usa y deja de abrir su contexto; AnalyzePanel toma el motor de getActiveUix(), no por prop-drilling.

Decisiones dentro del arreglo, declaradas:

  • No pasa por getOrCreateContext(). Decodificar funciona sobre un contexto suspendido, y resume() fuera de un gesto puede quedarse pendiente para siempre: un decode() que se espera desde la UI no puede colgar de eso. Se añadió ensureContext() — crear sin resumir.
  • No cachea. Los bytes crudos no tienen clave; la caché por URL sigue siendo de preload. Y devuelve null en vez de lanzar.
  • Desviación consciente de §16.2 punto 1: NO se añadió el acceso a los buffers cacheados. Hoy no tiene consumidor —Waveform no existe— y añadir superficie sin consumidor es justo lo que este plan critica en §16.1.

Medido en Chrome, importando un WAV real por el input[type=file]:

contextos creados al importar
antes 2 (el privado del decode se cerraba al acabar)
ahora 1, y el earcon posterior reutiliza ése

Y sigue funcionando: un tono de 440 Hz a 8 kHz, decodificado ahora sobre el contexto compartido a 44,1 kHz, se mide como 441 Hz — el resampleo no falsea el análisis.

Guards: dos casos nuevos en engine-sound.test.ts (decodifica sobre el contexto compartido sin llamar a resume(), y el segundo decode no crea otro contexto; null en bytes indecodificables y sin contexto).

16.4 A-3, ACEPTADO — S-1 no es cerrable por esta vía, y cuesta 2,2 KB

Medido: el art entero son 6.410 bytes minificados · 2.177 gzip. Eso es todo el síntoma S-1.

Y la propuesta de §16.2 punto 5 —«quitar el fallback del canal»— era incorrecta. Los sitios que importan el art como VALOR son tres, y dos son incondicionales por diseño:

Sitio ¿Se puede quitar?
chans/sound.ts — el fallback sí, pero rompe sound.test.ts, que construye por esa vía
active-uix.svelte.ts:40 — los dos modos de arranque no: crea el motor incondicionalmente por diseño
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) — 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.