|
|
# PLAN — `$sound` / `EngineSound`: extraer el motor de audio a un art
|
|
|
|
|
|
> **Tipo**: plan de ejecución por fases (process — efímero, NO fuente de verdad).
|
|
|
> **Fecha**: 2026-07-30 · **Estado**: **CERRADO — D-SND.1…D-SND.6 firmadas · F0 ✅ F1 ✅ F2 ✅ F3 ✅ F4 ✅ F5 ✅**.
|
|
|
> Cierre: `$sound` existe, sema lo consume por puerto sin importarlo, `uix.sound`
|
|
|
> está en la superficie pública y en la tabla ejecutable de contratos, y la
|
|
|
> invariante de **un solo `AudioContext` por documento** está cerrada en los DOS
|
|
|
> modos de arranque y **medida en Chrome** (§15).
|
|
|
> **Auditoría posterior (§16): 5 hallazgos, los 5 resueltos** — A-1 y A-5 con
|
|
|
> código, A-4 con tipos, A-2 y A-3 declarados por escrito tras medirlos. Lo que
|
|
|
> deja como doctrina corregida: **S-1 nunca fue cerrable** por esta vía (son
|
|
|
> 2,2 KB y el art es servicio de núcleo), y **la voz del earcon es diseño, no
|
|
|
> maquinaria** — el corte se trazó en la frontera de módulos, no en la de
|
|
|
> conocimiento (§16.1).
|
|
|
> **18 suites / 225 tests verdes en `active-uix` + `sema` + el art · `check` en la baseline exacta (73, 0 propios).**
|
|
|
> **Kickoff para sesión nueva**: _"Lee `docs/process/PLAN-audio-player.md`" — esta iniciativa ya no tiene trabajo pendiente._
|
|
|
>
|
|
|
> **Revisión adversarial (2026-07-30)**: el plan pasó una revisión externa que
|
|
|
> levantó 9 hallazgos, todos verificados contra el código y todos incorporados
|
|
|
> como enmiendas E-1…E-7 (§14). Los tres de severidad alta: el cableado de
|
|
|
> propiedad del engine no estaba escrito (E-1), faltaba la fila en la tabla
|
|
|
> ejecutable de contratos (E-2), y el auto-suspend de F3 saboteaba al
|
|
|
> reproductor —el consumidor que motiva el art— (E-3).
|
|
|
>
|
|
|
> **Origen**: análisis del reproductor de sonido (sesión 2026-07-30). Al buscar
|
|
|
> dónde vivía el motor de audio del ecosistema apareció que **ya existe uno, sin
|
|
|
> nombre de motor y en la carpeta equivocada**: 407 líneas de Web Audio dentro de
|
|
|
> `sema/chans/sound.ts`. Decisión del usuario: **aparcar el reproductor y
|
|
|
> extraer el motor primero**. La iniciativa del player queda bloqueada por ésta
|
|
|
> ([`PLAN-audio-player.md`](./PLAN-audio-player.md)).
|
|
|
>
|
|
|
> **Antes de escribir código**: firmar §11. Lo de aquí son propuestas razonadas.
|
|
|
|
|
|
---
|
|
|
|
|
|
## 1. La deuda, medida
|
|
|
|
|
|
[`sema/chans/sound.ts`](../../src/uix/sema/chans/sound.ts) — **407 líneas**, por naturaleza:
|
|
|
|
|
|
| Bloque | ≈ Líneas | Qué es |
|
|
|
| ----------------------------------------------------------------------------------------------------------------------------------- | -------- | --------- |
|
|
|
| Ciclo de vida del contexto — `getOrCreateContext` · `createContext` · `primeContextFromGesture` · `setupUnlockListener` · `dispose` | 85 | **Motor** |
|
|
|
| Síntesis — `synthesize` (2 osciladores + biquad lowpass + ADSR + modulador AM) · `applyContour` (±400 cents vía `detune`) | 115 | **Motor** |
|
|
|
| Samples — `playSample` · `preloadSamples` · cache de `AudioBuffer` | 55 | **Motor** |
|
|
|
| Pegamento de canal — `prepare` · `handle` · constructor · interfaz de opciones | 85 | Canal |
|
|
|
| Docblock + tipos | 65 | — |
|
|
|
|
|
|
**≈255 de 407 (≈63 %) es máquina Web Audio pura.** El código es bueno —el
|
|
|
docblock explica por qué el ADSR se recorta al 15 %/40 % de la nota y por qué el
|
|
|
sweep de contour subió de ±50 a ±400 cents— pero está en la carpeta equivocada.
|
|
|
|
|
|
## 2. Cuatro síntomas verificados de que es deuda, no diseño
|
|
|
|
|
|
| # | Síntoma | Evidencia |
|
|
|
| ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
|
|
| **S-1** | **Se paga siempre.** `engine.ts:29` importa `SoundChannel` como **valor estático**: toda app que construya un `EngineSemantic` mete el sintetizador en el bundle **aunque el sonido esté apagado** (que es el default — _sound and haptic default off_) | [`sema/engine.ts:29`](../../src/uix/sema/engine.ts) |
|
|
|
| **S-2** | **Ciudadanía incompleta.** Sin suspensión en pestaña oculta, sin presupuesto de contextos (el navegador corta ~6), sin recuperación. `$scene` hace exactamente esa lista «una vez»: frame loop, pausa fuera de vista, DPR, pérdida de contexto, presupuesto, teardown | `sound.ts` completo vs [`arts/scene`](../../src/arts/scene/README.md) |
|
|
|
| **S-3** | **Diagnóstico fuera de contrato.** Un solo `logger?.debug('sema.sound', …)` en el catch. El contrato de arts exige `diagnostics.ts` con catálogo tipado, `*_DIAGNOSTIC_EVENTS` en `consts.ts`, mensajes como constantes con nombre y errores tipados con guardas | [`sound.ts:138`](../../src/uix/sema/chans/sound.ts) vs [`arts/README.md`](../../src/arts/README.md) §Logger And Diagnostics Contract |
|
|
|
| **S-4** | **La costura ya está abierta y no la usa nadie.** `audioContextFactory?: () => AudioContext \| null` existe como opción del constructor desde el principio — alguien ya vio que el contexto no debía ser propiedad del canal | [`sound.ts:47`](../../src/uix/sema/chans/sound.ts) |
|
|
|
|
|
|
S-4 es el que cierra el caso: **es una deuda declarada sin escribir**.
|
|
|
|
|
|
## 3. El precedente — esto ya se hizo con motion
|
|
|
|
|
|
No es arquitectura nueva. Es el mismo movimiento, documentado:
|
|
|
|
|
|
> _«El runtime ya no vive en eidos: se relocalizó a `src/arts/motion` — un art —
|
|
|
> expuesto como `uix.motion` y consumido por ambas capas. Esto disuelve el
|
|
|
> acoplamiento soma→eidos. Eidos conserva la generación CSS y los DATOS de
|
|
|
> presets/keyframes/signatures; los tipos, el motor y los drivers viven en
|
|
|
> `$motion`.»_ — [`theming/motion.md`](../theming/motion.md)
|
|
|
|
|
|
| motion (hecho) | sound (este plan) |
|
|
|
| ----------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
|
|
| eidos conservó **datos**: keyframes · signatures · presets | sema conserva **doctrina**: `SOUND_LIBRARY` · `SOUND_TUNINGS` · resolvedores · cascada |
|
|
|
| `$motion` se llevó el **motor**: registro · `run` · drivers | `$sound` se lleva el **motor**: contexto · síntesis · samples · unlock |
|
|
|
| DOM por puerto estructural `MotionDom` | DOM por puerto estructural `SoundDom` |
|
|
|
| `defineEngineMotion()`, `initMode: 'lazy'` | `defineEngineSound()`, idéntico |
|
|
|
| Sin `dom` degrada (los drivers JS asientan) | Sin `dom` ni `AudioContext` degrada a silencio |
|
|
|
|
|
|
Precedentes hermanos del mismo patrón: [`$ethereal`](../../src/arts/ethereal/README.md)
|
|
|
(posicionamiento, ambas capas) y [`$scene`](../../src/arts/scene/README.md)
|
|
|
(ciudadanía WebGL hecha una vez; efectos = recursos compartidos).
|
|
|
|
|
|
**Nombre**: `$sound` / `EngineSound`. **No colisiona** con `$libs/sound`: el trío
|
|
|
`$libs/{sound,motion,haptic}` son las dimensiones de preferencia (`allow|reduce`)
|
|
|
y se queda intacto — igual que hoy conviven `$libs/motion` (prefs) y `$motion`
|
|
|
(el art).
|
|
|
|
|
|
## 4. La línea de corte
|
|
|
|
|
|
| `$sound` — `EngineSound` (art) | sema conserva |
|
|
|
| ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
|
|
|
| `AudioContext`: creación · unlock por gesto · resume · **suspend en pestaña oculta (nuevo)** · **presupuesto (nuevo)** · close | `SOUND_LIBRARY` (8 entradas) · `SOUND_TUNINGS` (13) |
|
|
|
| Master `GainNode` | Los 3 resolvedores de gesto: `resolveHandleDragSound` · `resolveSliderDragSound` · `resolveSplitterDragSound` |
|
|
|
| Síntesis: par de osciladores · biquad lowpass · ADSR · modulador AM · contour por `detune` | La cascada (5 capas) · memoria de frecuencia · árbitro de dominancia |
|
|
|
| Samples: fetch · decode · cache de `AudioBuffer` · precarga · fallback a síntesis | La política de reducción (`SemaPreferences`, `SOUND_REDUCE_GAIN_FACTOR`): **resuelve el nivel y pasa una ganancia** |
|
|
|
| Diagnostics + errores tipados (contrato de arts) | Qué firma para qué familia × intent |
|
|
|
| **Cero** conocimiento de familias, intents, cascada, morfo o `SemaPreferences` | **Cero** máquina de plataforma |
|
|
|
|
|
|
> ⚠️ **2026-08-12** — tres celdas de «sema conserva» quedaron rancias tras el
|
|
|
> cierre de este plan: `SOUND_LIBRARY` y `SOUND_TUNINGS` se retiraron el
|
|
|
> 2026-08-06 (el sonido se NOMBRA) y los 3 resolvedores de gesto el 2026-08-12
|
|
|
> ([`AUDIT-docs-code-ledger.md` §D10](./AUDIT-docs-code-ledger.md)) — los
|
|
|
> gestos suenan por REPETICIÓN (`step` por emisión). La tabla se conserva como
|
|
|
> registro del corte de 2026-07-30; la frontera viva es la misma: el art no
|
|
|
> importa nada de sema.
|
|
|
|
|
|
**Prueba objetiva del corte**: el art **no importa ni un tipo de `$uix/sema`**.
|
|
|
Recibe un valor con forma de `SoundSignature` (9 campos numéricos + `contour` +
|
|
|
`sampleUrl?`) y lo toca. Si algún día hace falta un import de sema, el corte
|
|
|
está mal trazado.
|
|
|
|
|
|
## 5. API — restringida por el contrato de regresión (resultado de F0)
|
|
|
|
|
|
### 5.1 El contrato de regresión (RC) — leído de [`sound.test.ts`](../../src/uix/sema/chans/sound.test.ts), baseline 5/5 verde
|
|
|
|
|
|
| ID | Lo que el test fija |
|
|
|
| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
|
| **RC-1** | El **constructor no tiene efectos**: ni `dom.listen`, ni `audioContextFactory` |
|
|
|
| **RC-2** | `prepare()` prima **síncronamente**: factory 1× · `createGain()` 1× · `gainNode.gain.value === masterGain` · `resume()` 1× · `getDocument()` **exactamente 1×** · `listen(document, ['pointerdown','mousedown','click','touchstart','keydown'], fn, true)` |
|
|
|
| **RC-3** | `dispose()` → cleanup del listener 1× **+ `ctx.close()` 1×** |
|
|
|
| **RC-4** | `preferences.sound === 'off'` → **el factory NUNCA se llama** (no se toca el audio en absoluto) |
|
|
|
| **RC-5** | `preferences.sound === 'reduce'` con contexto `running` → `gain.value === masterGain × 0.4`, y **la ganancia se fija ANTES** de que la síntesis reviente sobre el fake context (sin `createOscillator`) y el throw se absorbe |
|
|
|
| **RC-6** | `prepare()` con `channels: ['haptic']` explícito → no prima (ni factory ni listen) |
|
|
|
|
|
|
### 5.2 Lo que el RC impone al art (corrige la API que este plan proponía)
|
|
|
|
|
|
1. **Las opciones del art son un superconjunto de las del canal** — `audioContextFactory` · `fetchFn` · `dom` · `masterGain`. El harness inyecta por ahí; si el art no acepta esa superficie, RC-2/RC-4 no pueden pasar con los asserts intactos.
|
|
|
2. **Constructor sin efectos** (RC-1) — deja de ser preferencia y pasa a requisito verificado.
|
|
|
3. **`masterGain` es opción de construcción, no solo setter.** El plan proponía únicamente `setMasterGain()`; RC-2 exige el valor puesto en el nodo justo después de `prime()`. → **las dos cosas**.
|
|
|
4. **Orden observable dentro de `play()`**: fijar la ganancia **antes** de intentar sintetizar, y absorber el throw (RC-5). No es un detalle de implementación — está pineado.
|
|
|
5. **`prime()` es síncrono** (RC-2 no hace `await`): crea contexto + gain + listener y lanza `resume()` sin esperarlo.
|
|
|
6. **Solo el art registra el listener de unlock** — RC-2 exige `getDocument()` exactamente 1×; si el canal también lo llamara, serían 2.
|
|
|
7. **Regla de propiedad — _quien crea, dispone_.** RC-3 exige que `dispose()` del canal cierre el contexto, pero un engine **compartido**, inyectado por la raíz, no puede cerrarlo el canal. _(Corregido tras la revisión: F0 la declaró «regla NUEVA» y **no lo es** — es el idioma ya shipped del repo:_ `ownsMotion` / `ownsScene` _en [`active-uix.svelte.ts:183,188`](../../src/uix/active-uix/active-uix.svelte.ts) con su `if (owns…) …dispose()` en `:559-560`. F2 solo escribe `ownsSound` en la misma línea; no se inventa nada.)_
|
|
|
|
|
|
### 5.4 Desviaciones conscientes del _movimiento puro_ (declaradas, no escondidas)
|
|
|
|
|
|
F1 no fue byte a byte. Tres diferencias respecto al original, todas deliberadas:
|
|
|
|
|
|
| # | Desviación | Por qué |
|
|
|
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
|
| 1 | Categoría de log `'sema.sound'` → `'sound.engine'` | El art no puede reclamar el namespace de sema. F3 lo sustituye por el catálogo de diagnostics |
|
|
|
| 2 | `delay()` propio en lugar de `semaDelay` | El art no puede importar de `$uix/sema` — es la prueba objetiva del corte (§4). Misma lógica: scheduler si existe, `setTimeout` si no |
|
|
|
| 3 | **Flag `disposed`** — el original tiene **cero** ocurrencias; tras `dispose()` un `handle()` posterior **recreaba** el contexto. El art se queda inerte, y su test pinea el comportamiento nuevo | Defendible como arreglo (`dispose` debería ser terminal), pero **es un cambio de conducta** dentro de una fase cuyo contrato era «movimiento puro». Ningún test lo observaba, por eso pasó. Si al revisar se prefiere fidelidad estricta, se quita el flag y se borra ese caso del test |
|
|
|
|
|
|
**Veredicto de F0**: el criterio de D-SND.5 —_verde sin tocar un solo assert_— **es alcanzable**, y el split de §4 se sostiene: RC-4/RC-5/RC-6 pinean **política** (se queda en el canal) y RC-1/RC-2/RC-3 pinean **máquina** (se va al art).
|
|
|
|
|
|
### 5.3 La API resultante
|
|
|
|
|
|
```ts
|
|
|
const sound = createEngineSound({
|
|
|
dom, // puerto SoundDom
|
|
|
audioContextFactory, // RC-2/RC-4: costura de inyección
|
|
|
fetchFn, // samples en test
|
|
|
masterGain // RC-2: fijado en el nodo al crear el contexto
|
|
|
});
|
|
|
|
|
|
sound.prime(); // SÍNCRONO — crea + resume dentro del gesto (RC-2)
|
|
|
await sound.play(signature); // gain primero, síntesis después, throw absorbido (RC-5)
|
|
|
await sound.preload(urls); // decodifica samples a la cache
|
|
|
sound.setMasterGain(0.4); // atenuación; la POLÍTICA la decide quien llama
|
|
|
sound.suspend() / sound.resume(); // pestaña oculta, control externo (F3)
|
|
|
sound.dispose(); // idempotente: listener + close + cache (RC-3)
|
|
|
|
|
|
sound.state; // 'absent' | 'suspended' | 'running'
|
|
|
sound.context; // AudioContext | null — la salida cruda
|
|
|
```
|
|
|
|
|
|
`sound.context` es la pieza que resuelve el problema que originó todo esto:
|
|
|
**un solo contexto por documento**. Quien necesite grafo propio (analyser,
|
|
|
ganancia >1, `BufferSource`, scheduling preciso) lo cuelga del mismo contexto en
|
|
|
vez de abrir el segundo.
|
|
|
|
|
|
Forma `Engine*`, no `Active*`: métodos públicos sobre estado privado, sin
|
|
|
`$state` — como `EngineMotion` y `EngineScene`. Si algún día un medidor de nivel
|
|
|
necesita estado reactivo, lo lee por rAF, no por runas.
|
|
|
|
|
|
## 6. El puerto
|
|
|
|
|
|
```ts
|
|
|
export interface SoundDom {
|
|
|
getDocument(node?: …): Document;
|
|
|
listen(target: EventTarget, event: string | readonly string[], handler: EventListener, options?): () => void;
|
|
|
// F3: visibilidad para el suspend en pestaña oculta
|
|
|
}
|
|
|
```
|
|
|
|
|
|
El art no importa ningún otro art. `ActiveDom` satisface el puerto — misma forma
|
|
|
que `MotionDom` / `SceneDom`.
|
|
|
|
|
|
## 7. Cómo lo consume sema sin romper ni una regla
|
|
|
|
|
|
Un cambio, en [`engine.ts:284`](../../src/uix/sema/engine.ts):
|
|
|
|
|
|
```ts
|
|
|
soundChannel = new SoundChannel({
|
|
|
...soundOptions,
|
|
|
engine: opts.soundEngine, // ← el art, inyectado por la raíz de composición
|
|
|
dom: …, timers: …, preferences: …, logger: …
|
|
|
});
|
|
|
```
|
|
|
|
|
|
- **Sema no importa el art**: recibe un puerto estructural (`SoundOutput`) que
|
|
|
`$sound` satisface.
|
|
|
- **Sema no crea servicios**: los crea `ActiveUix` / `ActiveApp` — regla 1 de
|
|
|
ownership ([`active-uix.md`](../architecture/active-uix.md)).
|
|
|
- **Sigue degradando**: sin engine no hay sonido; sema sigue siendo ornamental y
|
|
|
su contrato mínimo (`sound` opcional) no cambia.
|
|
|
- **`SoundChannel` adelgaza a ~110 líneas**: resolver nivel → `engine.play(sig)`.
|
|
|
Y el import estático deja de arrastrar el sintetizador — **cierra S-1**.
|
|
|
❌ **Falso, corregido en §16 (A-3)**: `chans/sound.ts` importa
|
|
|
`createEngineSound` como VALOR para el fallback, así que la cadena
|
|
|
`engine.ts → chans/sound.ts → $sound` sigue siendo estática. S-1 sigue abierto.
|
|
|
|
|
|
## 8. El háptico NO se extrae — la asimetría es correcta
|
|
|
|
|
|
[`chans/haptic.ts`](../../src/uix/sema/chans/haptic.ts) son 175 líneas y su
|
|
|
«motor» es **una llamada**: `navigator.vibrate(pattern)`. No hay contexto, ni
|
|
|
grafo, ni cache, ni desbloqueo por gesto, ni presupuesto. **No hay máquina que
|
|
|
extraer** — un `$haptic` sería un art vacío.
|
|
|
|
|
|
Queda escrito para que nadie «arregle la asimetría» dentro de seis meses:
|
|
|
_sound tiene motor porque sintetiza; haptic no lo tiene porque delega en una
|
|
|
primitiva del sistema._ Se reevalúa el día que exista una Web Haptics API con
|
|
|
patrones compuestos.
|
|
|
|
|
|
## 9. Consumidores (la barra de ≥2 se cumple)
|
|
|
|
|
|
| Consumidor | Qué necesita | Estado |
|
|
|
| ----------------------- | --------------------------------------------------------------- | --------------------------------------- |
|
|
|
| **sema `SoundChannel`** | todo lo que ya usa | existe hoy |
|
|
|
| **`media-player`** | `sound.context`: ganancia >1, analyser, precisión de segmento | [plan aparcado](./PLAN-audio-player.md) |
|
|
|
| **`Waveform`** | `decodeAudioData` + cache para picos | diferido |
|
|
|
| **Visualizador** | `AnalyserNode` → efecto de `$scene` (**los dos arts componen**) | diferido |
|
|
|
| **`proof-of-human`** | reto de audio (registrado como v2) | componente existe |
|
|
|
| **`Aura`** | la voz de la familia `delegate` | reservado |
|
|
|
| **`chat-*`** | notas de voz | familia existe |
|
|
|
|
|
|
Y el pago que ninguna referencia da: con un motor dueño del earcon **y** del
|
|
|
contenido, el **ducking** (bajar la UI mientras suena el contenido) pasa de
|
|
|
parche de cascada a capacidad del motor.
|
|
|
|
|
|
## 10. Lo que el art NO se lleva
|
|
|
|
|
|
Audio espacial · cadenas de efectos/DSP · mezclador multipista · grabación ·
|
|
|
MIDI. El art es **un contexto bien gobernado + síntesis de earcon + reproducción
|
|
|
de sample**. Todo lo demás cuelga de `sound.context`, que para eso se expone.
|
|
|
|
|
|
## 11. Decisiones (gate del usuario — SIN FIRMAR)
|
|
|
|
|
|
| ID | Cuestión | Recomendación |
|
|
|
| ----------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
|
| **D-SND.1** | ¿Se crea el art? ¿Con qué nombre? | **Sí** · `$sound` / `EngineSound` (convive con `$libs/sound` igual que `$motion` con `$libs/motion`) |
|
|
|
| **D-SND.2** | La línea de corte (§4) | Tal cual, con la prueba objetiva: **el art no importa ni un tipo de `$uix/sema`** |
|
|
|
| **D-SND.3** | ¿Se extrae también el háptico? | **No** — asimetría justificada por escrito (§8) |
|
|
|
| **D-SND.4** | ¿Cómo lo consume sema? | Puerto estructural inyectado por la raíz. Sema no importa el art, no crea servicios, sigue degradando |
|
|
|
| **D-SND.5** | Alcance del v1 del art | **Mover primero (value-preserving), arreglar después**: F3 cierra suspend-en-oculta, presupuesto y diagnostics. Mover-y-arreglar en el mismo pase es como se rompen los refactors |
|
|
|
| **D-SND.6** | ¿`uix.sound` desde el día 1? | **Sí**: `defineEngineSound()` en `$active-app/service-factories` (`initMode: 'lazy'`) **y** `uix.sound` en `ActiveUix`, junto a `uix.motion` / `uix.timers` — es la superficie que el player y `Waveform` van a pedir |
|
|
|
|
|
|
## 12. Fases (tras el gate)
|
|
|
|
|
|
| Fase | Contenido | Verify |
|
|
|
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
|
| **F0 — Firma** ✅ **HECHA 2026-07-30** | D-SND.1–6 firmadas en la columna recomendada · `sound.test.ts` leído entero · **baseline 5/5 verde** · contrato de regresión RC-1…RC-6 derivado y §5 corregida con las 7 restricciones que impone (incl. la regla de propiedad _quien crea, dispone_, que no estaba en el plan) | ✅ `npx vitest run src/uix/sema/chans/sound.test.ts` → 5/5 |
|
|
|
| **F1 — El art** ✅ **HECHA 2026-07-30** | `src/arts/sound/`: `types.ts` (puertos `SoundDom` + `SoundTimers`, `SoundSignature` propia) · `engine-sound.ts` (movimiento verbatim de contexto/síntesis/contour/samples + `delay` propio espejo de `semaDelay`) · `index.ts` (re-exports con nombre) · `README.md` · `engine-sound.test.ts` · alias `$sound` en `vite.config.ts` + `svelte.config.js`. **Movimiento puro**: cero cambios de comportamiento, cero imports de `$uix/sema` | ✅ suite del art **8/8** · ✅ `check` **0 errores propios** (los 73 del repo son preexistentes de la rama: `web/routes/alpha`, `demos/animations`, …) · ✅ baseline de sema intacta **5/5** · ⚠️ el alias `$sound` no se ejercita hasta F2 (nadie lo importa aún) |
|
|
|
| **F2 — Sema consume** ✅ **HECHA 2026-07-30** — `SoundChannel` **407 → 136 líneas** · `soundEngine` en `EngineSemanticOptions` · `uix.sound` en ambos modos con `ownsSound` · fila `sound` en `contracts.ts` (+ `standaloneCreates` / `publicSurface`) · `defineEngineSound()` · `sound-port.test.ts` nuevo (guard de tipos E-6 + la regla de propiedad) | **(E-1) El cableado de propiedad, explícito** — `ActiveUix` crea `EngineSound` **incondicionalmente** (RC-1 lo hace gratis: sin contexto hasta `prime()`), lo expone como `uix.sound` y se lo pasa a `EngineSemantic` vía `opts.soundEngine`; en attach, `app.sound ?? createEngineSound(…)` + **`ownsSound`**, espejo literal de `ownsMotion`/`ownsScene`. El canal **solo crea el suyo si no se lo inyectan** (uso directo y tests — RC-3 sigue verde) · **(E-2)** fila `sound` en `contracts.ts` + su assert en `contracts.test.ts` · **(E-4)** resolver el `instanceof SoundChannel` del preload ([`engine.ts:296`](../../src/uix/sema/engine.ts)) → delegar `preloadSamples` al art, y **conservar viva** la vía `isChannel(opts.sound)` de `:281` · `SoundChannel` adelgaza a consumidor · `defineEngineSound()` | **(E-3, gate endurecido)** `vitest run src/uix/sema` — **las 14 suites**, no solo `sound.test.ts` — y **sin tocar un solo assert** · suite del art verde · `contracts.test.ts` verde · **(E-6)** guard de asignabilidad de tipos, del lado de sema (sema puede importar el art; el art a sema **nunca**) · navegador `/temas/sema`: comprobar **por JS que `uix.sound.context` es el MISMO objeto** que usa el canal (la prueba de que hay un solo contexto), que el unlock dispara, y oír 2-3 familias × intents<br><br>**RESULTADO**: ✅ sema **15/15 suites · 184/184**, sin tocar un assert de `sound.test.ts` · ✅ art **8/8** · ✅ `check` **73 errores, los mismos que antes de F2, 0 propios** · ✅ `contracts.test.ts` 35/38 — los 3 rojos son **preexistentes y ajenos** (`aura`, `menubar`, `radio-group`; el fichero ya venía modificado de otra sesión) y el guard que cubre esto —_pins ActiveUix public service names_— pasa · ✅ **camino de audio extremo a extremo**: [`sound-e2e.test.ts`](../../src/uix/sema/chans/sound-e2e.test.ts) — `emit → cascada → SoundChannel → $sound → grafo real` (2 osciladores + biquad + `start()`), el caso mudo (`channels:['haptic']`) y **el ciclo dispose/rebuild del estudio** ×3. 3/3<br>✅ **audible confirmado por el usuario** en `/temas/sema` tras limpiar el entorno.<br><br>**RETIRADA — la primera "verificación" de navegador era vacua.** Conté 1 `AudioContext` en `/temas/sema` y lo presenté como prueba de la invariante de contexto único. No lo era: esa página monta **su propio** `EngineSemantic` (`_lib/audition.ts:35-43`, `sound: true`) y el `createActiveUix` de la página pasa `sound: false`, así que el contexto contado era el del estudio y `uix.sound` no había creado ninguno (no tiene efectos). Medí una cosa y afirmé otra. La prueba buena es el test e2e de arriba; **la invariante de contexto único sigue sin verificarse en un navegador** y necesita una página que enchufe `soundEngine` (hoy ninguna lo hace — ver la nota de attach en F2). → **Cerrado en §15**: el estudio ya enchufa `uix.sound`, y la invariante está medida en Chrome (10 contextos → 1). |
|
|
|
| **F3 — Ciudadanía** ✅ **HECHA 2026-07-30** — `autoSuspend` opt-in (default `false`, un solo `getDocument()` para los dos listeners, `suspend()`/`resume()` explícitos ganan sobre la política) · **el aviso se afinó al ejecutar**: no al construir un segundo ENGINE (inocuo — `ActiveUix` crea uno siempre por diseño) sino al ir vivo un segundo **CONTEXTO**, que es el anti-patrón real · `consts.ts` con categoría y mensajes con nombre · **sin `diagnostics.ts` ni `errors.ts`, y es decisión**: espejo de `$scene`, todo fallo aquí es degradación documentada, no error de programador · 3 tests nuevos (11/11, estables en 3 pasadas) | Cierre de S-2 y S-3: **(E-5) auto-suspend en pestaña oculta = OPT-IN** (`autoSuspend?: boolean`, default `false`) — suspender es política del que compone, no del motor: el earcon quiere callarse con la pestaña oculta y **un podcast NO**, y el player cuelga su grafo de este mismo contexto · **(E-5) el «presupuesto» se reformula**: `$scene` presupuesta N contextos GL concurrentes, pero este engine ES el singleton → lo útil es **avisar (warn) si se crea un segundo `EngineSound` en el documento**, que es justo el anti-patrón que originó el art · `diagnostics.ts` + `errors.ts` + `consts.ts` según el contrato de arts (sustituye la desviación 1 de §5.4) | tests nuevos · `check` · README del art completo |
|
|
|
| **F4 — Docs** ✅ **HECHA 2026-07-30** | `arts/README.md`: fila del mapa + alias `$sound` + entrada en el grafo de dependencias · `architecture/sema.md`: la sección `SoundChannel` reescrita («doctrina aquí, máquina en `$sound`») + el árbol de ficheros · README del art ampliado con `autoSuspend` y el aviso de segundo contexto. `docs/README.md` NO tocado: el art se indexa desde `arts/README.md`, que es lo que E5 prescribe para artefactos | ⚠️ `docs:check` **1 error, ajeno y preexistente**: `eidos/components/callout/README.md:23` dice «8 roles» y `COLOR_ROLES.length` es 9. Fichero commiteado, sin relación con sonido — **no lo toco**, queda reportado |
|
|
|
| **F5 — Desbloqueo** ✅ **HECHA 2026-07-30** | [`PLAN-audio-player.md`](./PLAN-audio-player.md) marcado **DESBLOQUEADO**; las 5 correcciones de su cabecera incorporadas al cuerpo; **D-AP.11 escrita contra el art** (su premisa —«no hay motor»— era falsa) y **D-AP.12** (segmentos) añadida — las dos existían como referencia sin fila en §4 | El plan del player queda listo para su gate de firma (D-AP.1…D-AP.12). Cero código del player en esta sesión |
|
|
|
|
|
|
**Método**: no-cascada — una pieza, verificar, la siguiente
|
|
|
([`_cierre.md`](../audit/components/_cierre.md)). Gates completos al cerrar cada
|
|
|
fase.
|
|
|
|
|
|
## 13. Riesgos declarados
|
|
|
|
|
|
| Riesgo | Acotación |
|
|
|
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
|
| Tocar sema, la capa de doctrina más estricta | Refactor **value-preserving**: mismo sonido, mismo timing. `sound.test.ts` verde antes y después, **sin reescribir asserts** — si un assert hay que tocarlo, el movimiento dejó de ser puro |
|
|
|
| Regresión perceptiva que los tests no oyen | Verificación en navegador con el laboratorio existente (`/temas/sema`), earcon a earcon. Los tests mockean el `AudioContext`; el oído no |
|
|
|
| Sobre-alcance (mover + arreglar a la vez) | D-SND.5: dos fases separadas |
|
|
|
| Que el art nazca sin consumidor | Ya tiene uno real (sema) y la barra de ≥2 la cumple el player, que es el motivo de la extracción |
|
|
|
| Que alguien «arregle» la asimetría con el háptico | §8, por escrito |
|
|
|
| **Levantar un dev server para verificar** | `npm run dev` es `vite dev --force`: **reescribe `node_modules/.vite/deps`** por debajo del servidor que el usuario ya tenía vivo → dos runtimes de Svelte → la página deja de hidratar (`lifecycle_outside_component`) → parece una regresión de audio que no existe. **Ocurrió el 2026-07-30 y costó una falsa alarma.** Verificar con tests deterministas (dobles de `AudioContext`), no levantando servidores |
|
|
|
|
|
|
## 14. Registro de la revisión adversarial (2026-07-30)
|
|
|
|
|
|
Revisión externa del plan tras cerrar F1. **9 hallazgos, los 9 verificados contra
|
|
|
el código, los 9 incorporados.** Se registra aquí porque tres de ellos cambian
|
|
|
fases que aún no se han ejecutado, y porque dos corrigen afirmaciones que este
|
|
|
mismo plan hacía.
|
|
|
|
|
|
| Enmienda | Hallazgo | Dónde aterriza |
|
|
|
| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
|
| **E-1** | **ALTA** — F2 no decía **quién crea el engine** en los dos modos de boot. Con `uix.sound` creando uno y el canal creando otro cuando no se le inyecta, se vuelve a **dos contextos**: el anti-patrón original, ahora con dos dueños «legítimos» | F2 · resuelto con el idioma ya shipped (`ownsSound`, espejo de `ownsMotion`/`ownsScene`) |
|
|
|
| **E-2** | **ALTA** — `uix.sound` toca [`contracts.ts`](../../src/uix/contracts.ts), la tabla EJECUTABLE de contratos mínimos, cuya propia doctrina dice que _«future changes must derive from this table»_. Añadir un servicio sin su fila la viola | F2 · fila `sound` + assert |
|
|
|
| **E-3** | **ALTA** — el auto-suspend de F3 **sabotea al reproductor**: la gente escucha podcasts con la pestaña oculta, y el player cuelga su grafo de este contexto. Política correcta para earcons, incorrecta para contenido | F3 · opt-in, default `false` |
|
|
|
| **E-4** | MEDIA — el `instanceof SoundChannel` que guarda el preload de los packs ([`engine.ts:296`](../../src/uix/sema/engine.ts)) y la vía `isChannel(opts.sound)` de `:281` eran cabos sueltos: romperían el preload en silencio | F2 |
|
|
|
| **E-5** | BAJA — el «presupuesto de contextos» venía copiado de `$scene` sin caso propio: aquí el engine ES el singleton | F3 · warn al segundo engine |
|
|
|
| **E-6** | MEDIA — `SoundSignature` está duplicada sin guard: la asignabilidad estructural es silenciosa en la dirección peligrosa (un campo nuevo en sema se ignora sin error) | F2 · guard de tipos **del lado de sema** |
|
|
|
| **E-7** | DOC — la cabecera decía «F0 pendiente — SIN FIRMAR» con F0 y F1 ya cerradas; y las desviaciones del _movimiento puro_ no estaban declaradas | cabecera · §5.4 |
|
|
|
|
|
|
**Dos correcciones que la revisión provocó sobre el propio plan:**
|
|
|
|
|
|
1. **La «regla de propiedad NUEVA» de F0 no era nueva.** `ownsMotion` / `ownsScene`
|
|
|
([`active-uix.svelte.ts:183,188,559-560`](../../src/uix/active-uix/active-uix.svelte.ts))
|
|
|
ya es el idioma del repo. F2 lo copia; no inventa.
|
|
|
2. **F1 tenía una tercera desviación sin declarar** que la revisión externa
|
|
|
tampoco vio: el flag `disposed` (§5.4, fila 3). El original recreaba el
|
|
|
contexto tras `dispose()`; el art se queda inerte, y su propio test pinea el
|
|
|
comportamiento nuevo. Ningún test lo observaba — por eso pasó.
|
|
|
|
|
|
## 15. La invariante, cerrada y MEDIDA (2026-07-30, sesión de cierre)
|
|
|
|
|
|
F2 dejó dos deudas escritas: la invariante de contexto único **sin verificar en
|
|
|
navegador**, y ninguna página que enchufase `soundEngine`. Las dos quedan
|
|
|
cerradas aquí, y por el camino apareció un tercer agujero que nadie había visto.
|
|
|
|
|
|
### 15.1 Lo que la medición desmintió
|
|
|
|
|
|
El handoff anterior predecía que abrir `/temas/sema` **avisaría por consola** del
|
|
|
segundo contexto. **No avisa, y no debía**: el aviso cuenta contextos VIVOS, y en
|
|
|
esa página sólo había uno — el privado del estudio. `uix.sound` no abría ninguno
|
|
|
porque nada lo primaba (`events: { sound: false }`), que es exactamente lo que la
|
|
|
retirada de F2 ya había reconocido. Predicción incorrecta, comprobada antes de
|
|
|
tocar nada.
|
|
|
|
|
|
### 15.2 Lo que sí estaba roto — el trasiego de contextos
|
|
|
|
|
|
Instrumentando `AudioContext` en Chrome sobre el servidor del usuario (sin
|
|
|
levantar otro — §13), con 8 ciclos de _editar el draft → disparar_:
|
|
|
|
|
|
| | contextos creados | cerrados | vivos |
|
|
|
| -------------------------------------- | ----------------- | -------- | ----- |
|
|
|
| **Antes** (motor privado del canal) | **10** | 9 | 1 |
|
|
|
| **Después** (`soundEngine: uix.sound`) | **1** | 0 | 1 |
|
|
|
|
|
|
Cada edición del draft reconstruye el `EngineSemantic` → `dispose()` del canal →
|
|
|
`ctx.close()` → el siguiente disparo abre uno nuevo. Además de la invariante, se
|
|
|
perdía **la caché de samples decodificados** en cada edición (el `.wav` importado
|
|
|
se volvía a bajar y decodificar). Los osciladores siguen construyéndose en cada
|
|
|
disparo después del arreglo, así que el earcon no se perdió por el camino.
|
|
|
|
|
|
Arreglo: [`web/routes/temas/sema/_lib/audition.ts`](../../web/routes/temas/sema/_lib/audition.ts)
|
|
|
acepta `soundEngine` y [`+page.svelte`](../../web/routes/temas/sema/+page.svelte)
|
|
|
le pasa `uix.sound`. Guard determinista: _«an injected engine survives the
|
|
|
rebuilds: ONE context for the whole page»_ en
|
|
|
[`sound-e2e.test.ts`](../../src/uix/sema/chans/sound-e2e.test.ts).
|
|
|
|
|
|
### 15.3 E-8 — el modo attach tenía la invariante ABIERTA (hallazgo nuevo)
|
|
|
|
|
|
E-1 dio por cableada la propiedad «en los dos modos de arranque». En attach **no
|
|
|
lo estaba**: [`defineUixServices`](../../src/uix/active-uix/services.ts)
|
|
|
registraba `motion` y `scene` junto a `dom` pero **no `sound`**, y
|
|
|
[`defineEngineSemantic`](../../src/uix/sema/define-engine-semantic.ts) no
|
|
|
reenviaba `soundEngine`. Una app en attach con sonido activo tenía el motor
|
|
|
privado del canal **más** `uix.sound` — dos motores, y dos contextos en cuanto el
|
|
|
reproductor primase el suyo. `attachActiveUix` **documentaba** el footgun en un
|
|
|
comentario en vez de cerrarlo, y `contracts.ts` ya prometía
|
|
|
`singleContextPerDocument: true`.
|
|
|
|
|
|
Cerrado (decisión del usuario, 2026-07-30): `defineUixServices` registra
|
|
|
`sound`, `defineEngineSemantic` lo declara como `serviceDependencies: ['dom',
|
|
|
'sound']` y lo reenvía (degrada a `undefined` como `dom`), la nota de
|
|
|
`attachActiveUix` pasa a describir el cierre, y la fila `events` de
|
|
|
`contracts.ts` gana `serviceDependencies` — de la que el assert ahora deriva en
|
|
|
vez de hardcodear.
|
|
|
|
|
|
Guard: _«routes the app events engine through the app sound service (ONE
|
|
|
AudioContext)»_ en [`active-uix.svelte.test.ts`](../../src/uix/active-uix/active-uix.svelte.test.ts).
|
|
|
**Verificado que no es vacuo**: quitando el reenvío, el test falla
|
|
|
(`expected null not to be null`) — la lección de la retirada de F2, aplicada.
|
|
|
|
|
|
## 16. Auditoría posterior al cierre — 5 hallazgos ABIERTOS (2026-07-30)
|
|
|
|
|
|
Al releer lo entregado **cuestionando el diseño y no sólo su coherencia con este
|
|
|
plan**, aparecen cinco cosas. Ninguna invalida lo commiteado; tres necesitan
|
|
|
decisión del usuario. Se escriben aquí porque este plan afirma cosas que estos
|
|
|
hallazgos corrigen.
|
|
|
|
|
|
| # | Hallazgo | Evidencia |
|
|
|
| -------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
|
| **A-1** ✅ **CERRADO 2026-07-30** (§16.3) | **El estudio SIGUE abriendo un segundo contexto.** [`analyze.ts:32`](../../web/routes/temas/sema/_lib/analyze.ts) hace `new Ctor()` propio para decodificar el `.wav` importado. Medido inyectando un WAV real por el `input[type=file]`: **1 → 2 contextos** (el segundo se cierra al acabar el decode). La medición de §15 sólo recorría el camino del earcon | navegador |
|
|
|
| **A-2** ✅ **ESCRITO 2026-07-30** (no se puede «arreglar» sin parchear el constructor global; queda declarado en `consts.ts` y en el README) | **El aviso de «segundo contexto» es ciego a los contextos crudos.** `liveContexts` cuenta sólo los que crea el art, así que un `new AudioContext()` en cualquier otro sitio —A-1, exactamente— es invisible. Detecta cableado doble de _engines_, no el antipatrón que su mensaje nombra | [`engine-sound.ts:60`](../../src/arts/sound/engine-sound.ts) |
|
|
|
| **A-3** ✅ **ACEPTADO 2026-07-30** (§16.4) | **S-1 NO está cerrado**, y §7 afirma lo contrario. `engine.ts:29` importa `SoundChannel` como valor y `chans/sound.ts:7` importa `createEngineSound` como valor: toda app que construya un `EngineSemantic` sigue metiendo el sintetizador en el bundle con el sonido apagado. La cadena es un salto más larga e igual de estática. Y «sema no importa el art» es inexacto: cierto para la _instancia_, falso para el _grafo de módulos_ | [`engine.ts:29`](../../src/uix/sema/engine.ts) · [`sound.ts:7`](../../src/uix/sema/chans/sound.ts) |
|
|
|
| **A-4** ✅ **CERRADO 2026-07-30** (§16.5) | **El canal tira en silencio las opciones de construcción cuando le inyectan el motor**: `masterGain`, `audioContextFactory`, `fetchFn`, `dom`, `timers`, `logger`. Sólo conserva `preferences`. En standalone pasa desde F2; **el cableado de attack de §15.3 lo extiende a attach**, donde `sound: { masterGain: 0.4 }` era honrado y ahora es un no-op mudo. Ningún test lo pinea. Efecto lateral: el nombre de servicio `sound` queda reservado de facto | [`sound.ts:80`](../../src/uix/sema/chans/sound.ts) |
|
|
|
| **A-5** ✅ **CERRADO 2026-07-30** (§16.6) | **`gainScale` —parámetro de UNA reproducción— se escribe en el nodo master COMPARTIDO**, del que cuelgan todos los earcons, incluidos los que ya suenan. Con un consumidor no se nota; con dos —la premisa del art— el ducking del reproductor y la reducción de sema se pisan, y un earcon en vuelo salta de nivel a media nota. El grafo ya tiene su nodo de envolvente, que es el sitio correcto. **`RC-5` pineó ese orden como si fuera diseño** | [`engine-sound.ts:141`](../../src/arts/sound/engine-sound.ts) |
|
|
|
|
|
|
### 16.1 Y la pregunta de fondo: ¿el corte está bien trazado?
|
|
|
|
|
|
La prueba objetiva de §4 —_el art no importa ni un tipo de `$uix/sema`_— se
|
|
|
cumple **en la frontera de módulos, no en la de conocimiento**. Dentro de la
|
|
|
«maquinaria» hay cuatro decisiones perceptivas calibradas contra el vocabulario
|
|
|
de sema: la **quinta** a 1.5× mezclada a 0.3, el **ADSR recortado a 15 %/40 %**
|
|
|
(_«una nota de commit son 100 ms…»_), el **contour de ±400 cents** (_«±50 era
|
|
|
imperceptible a las duraciones de hold típicas»_) y el mapeo de **`roughness` a
|
|
|
profundidad y velocidad de AM**. El art no importa sema: sema está horneada
|
|
|
dentro del art como constantes.
|
|
|
|
|
|
Compárese con el precedente que §3 invoca: en `$motion` la capa conservó los
|
|
|
DATOS (keyframes, presets, curvas) y el art se llevó MECANISMOS genéricos
|
|
|
(drivers). Aquí el mecanismo se llevó también el diseño. **El corte de sound no
|
|
|
replica el de motion tan de cerca como este plan afirma.**
|
|
|
|
|
|
Y la superficie lo confirma: `prime`/`suspend`/`resume`/`context`/`dispose` es
|
|
|
**gobierno de contexto** (genérico, con consumidores reales hoy); `play(signature)`
|
|
|
es **la voz de la UI** (un solo consumidor, y calibrada para él). Son dos
|
|
|
artefactos pegados.
|
|
|
|
|
|
Peor: la API **no puede servir a los consumidores que la justificaron**.
|
|
|
`Waveform` necesita los `AudioBuffer` para leer picos y `preload(urls)` los
|
|
|
esconde en una caché privada; el analizador del estudio necesita decodificar un
|
|
|
`File` y no hay entrada por bytes — **por eso A-1 abre su propio contexto**. De
|
|
|
los tres consumidores que la barra de ≥2 invocaba, la superficie sólo sirve a
|
|
|
uno (el player, vía `context`).
|
|
|
|
|
|
### 16.2 Lo propuesto, por orden de valor
|
|
|
|
|
|
> **Estado: los 5 aplicados** (firmados 2026-07-30). El punto 5 de esta lista
|
|
|
> resultó **incorrecto** al medirlo — quitar el fallback no cierra S-1, porque
|
|
|
> las raíces de composición importan el art igual. Corregido en §16.4.
|
|
|
|
|
|
1. **`decode(bytes): Promise<AudioBuffer | null>` en el art**, y devolver los
|
|
|
buffers cacheados. Cierra A-1, es lo que `Waveform` va a pedir, y convierte
|
|
|
la barra de ≥2 en algo real HOY en vez de una promesa.
|
|
|
2. **Mover `gainScale` del master al grafo del earcon** (A-5), reescribiendo
|
|
|
`RC-5` — que es justo lo que un contrato de regresión debe permitir cuando lo
|
|
|
que pineaba era un defecto.
|
|
|
3. **Declarar las cuatro constantes como _la voz del art_, no como maquinaria**.
|
|
|
Si algún día un segundo consumidor quiere otra voz, ese es el momento de
|
|
|
partir el art en dos: gobierno de contexto + voz. No antes.
|
|
|
4. **Decidir A-4**: que las opciones explícitas ganen al motor inyectado, o que
|
|
|
avisen. Y reservar por escrito el nombre de servicio `sound`.
|
|
|
5. **A-2 y A-3 se escriben donde prometen de más** (README del art, §2, §7) en
|
|
|
cuanto se decida 1–4; A-3 se cierra de verdad quitando el fallback del canal
|
|
|
o difiriéndolo con `import()`, que tras §15.3 ya no lo alcanza ninguna raíz
|
|
|
de composición.
|
|
|
|
|
|
**No se recomienda rehacer la extracción.** Fue value-preserving, está verde, y
|
|
|
mover la síntesis otra vez no paga el riesgo.
|
|
|
|
|
|
### 16.3 A-1, cerrado — `decode()` en el art (2026-07-30)
|
|
|
|
|
|
`EngineSound.decode(data: ArrayBuffer): Promise<AudioBuffer | null>`: la segunda
|
|
|
puerta del art, la que faltaba para los consumidores que quieren **las muestras**
|
|
|
y no un grafo. `analyze.ts` la usa y deja de abrir su contexto; `AnalyzePanel`
|
|
|
toma el motor de `getActiveUix()`, no por prop-drilling.
|
|
|
|
|
|
Decisiones dentro del arreglo, declaradas:
|
|
|
|
|
|
- **No pasa por `getOrCreateContext()`.** Decodificar funciona sobre un contexto
|
|
|
suspendido, y `resume()` fuera de un gesto puede quedarse pendiente para
|
|
|
siempre: un `decode()` que se espera desde la UI no puede colgar de eso. Se
|
|
|
añadió `ensureContext()` — crear sin resumir.
|
|
|
- **No cachea.** Los bytes crudos no tienen clave; la caché por URL sigue siendo
|
|
|
de `preload`. Y devuelve `null` en vez de lanzar.
|
|
|
- **Desviación consciente de §16.2 punto 1**: NO se añadió el acceso a los
|
|
|
buffers cacheados. Hoy no tiene consumidor —`Waveform` no existe— y añadir
|
|
|
superficie sin consumidor es justo lo que este plan critica en §16.1.
|
|
|
|
|
|
**Medido en Chrome**, importando un WAV real por el `input[type=file]`:
|
|
|
|
|
|
| | contextos creados al importar |
|
|
|
| ----- | -------------------------------------------------- |
|
|
|
| antes | **2** (el privado del decode se cerraba al acabar) |
|
|
|
| ahora | **1**, y el earcon posterior reutiliza ése |
|
|
|
|
|
|
Y sigue funcionando: un tono de 440 Hz a 8 kHz, decodificado ahora sobre el
|
|
|
contexto compartido a 44,1 kHz, se mide como **441 Hz** — el resampleo no
|
|
|
falsea el análisis.
|
|
|
|
|
|
Guards: dos casos nuevos en `engine-sound.test.ts` (decodifica sobre el contexto
|
|
|
compartido sin llamar a `resume()`, y el segundo `decode` **no** crea otro
|
|
|
contexto; `null` en bytes indecodificables y sin contexto).
|
|
|
|
|
|
### 16.4 A-3, ACEPTADO — S-1 no es cerrable por esta vía, y cuesta 2,2 KB
|
|
|
|
|
|
**Medido**: el art entero son **6.410 bytes minificados · 2.177 gzip**. Eso es
|
|
|
todo el síntoma S-1.
|
|
|
|
|
|
Y la propuesta de §16.2 punto 5 —«quitar el fallback del canal»— **era
|
|
|
incorrecta**. Los sitios que importan el art como VALOR son tres, y dos son
|
|
|
incondicionales por diseño:
|
|
|
|
|
|
| Sitio | ¿Se puede quitar? |
|
|
|
| ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------- |
|
|
|
| [`chans/sound.ts`](../../src/uix/sema/chans/sound.ts) — el fallback | sí, pero rompe `sound.test.ts`, que construye por esa vía |
|
|
|
| [`active-uix.svelte.ts:40`](../../src/uix/active-uix/active-uix.svelte.ts) — los dos modos de arranque | **no**: crea el motor incondicionalmente por diseño |
|
|
|
| [`service-factories/sound.ts`](../../src/arts/active-app/service-factories/sound.ts) — `defineEngineSound()` | **no**, y §15.3 lo metió además en `defineUixServices` |
|
|
|
|
|
|
Quitar el fallback dejaría el art fuera del bundle sólo de una app que no use
|
|
|
`createActiveUix` **ni** `defineUixServices` — es decir, de ninguna. Cerrarlo de
|
|
|
verdad exige carga diferida, y ahí choca con que **`prime()` debe ser síncrono
|
|
|
dentro del gesto**: un `await import()` en el handler llega tarde y el contexto
|
|
|
se queda suspendido. Habría que precargar el chunk antes del primer gesto (se
|
|
|
descarga igual) y volver `uix.sound` perezoso, cambiando la superficie pública
|
|
|
que `contracts.ts` declara como `readonly sound: EngineSound` siempre presente.
|
|
|
|
|
|
**Decisión: aceptar.** El art es un servicio del núcleo, como `motion` y
|
|
|
`scene`, y 2,2 KB no pagan un cambio de superficie pública. Lo que sí queda
|
|
|
corregido es la doctrina: **S-1 era el más débil de los cuatro síntomas**; los
|
|
|
que justificaban la extracción eran S-2 (ciudadanía) y S-4 (la costura de
|
|
|
inyección declarada sin escribir), más el reparto doctrina/máquina.
|
|
|
|
|
|
### 16.5 A-4, cerrado — unión discriminada, aviso, y `fetchFn` → `fetcher`
|
|
|
|
|
|
`SoundChannelOptions` deja de ser un objeto plano y pasa a **unión de dos
|
|
|
formas**: `{ engine, preferences?, logger? }` (la raíz es dueña del motor) **o**
|
|
|
la forma de construcción con `audioContextFactory` / `fetcher` / `dom` /
|
|
|
`masterGain` / `timers`. Nunca ambas: un motor llega ya construido, así que sus
|
|
|
opciones de construcción no significan nada a su lado.
|
|
|
|
|
|
- **La defensa primaria es el tipo** —doctrina de la casa, _types over lint_—
|
|
|
con un guard `@ts-expect-error` en `sound-port.test.ts` que falla `check` el
|
|
|
día que la unión deje de rechazar la forma mezclada.
|
|
|
- **Aviso por logger** para quien llame desde JS, enumerando las claves
|
|
|
ignoradas. El silencio fue lo que hizo que esto pasara desapercibido.
|
|
|
- `engine.ts` pasa de un spread a **tres ramas** en orden de precedencia: motor
|
|
|
del canal → motor de la raíz → el canal construye el suyo.
|
|
|
- **`fetchFn` → `fetcher`**, para alinearse con `$perm` ([types.ts:49](../../src/arts/perm/types.ts)) — un nombre por
|
|
|
concepto. Sobre el otro nombre discutido: **`audioContextFactory` se queda**;
|
|
|
es el patrón `idFactory` que ya usan `$logger` y `$bus`, y nombra el tipo de
|
|
|
plataforma exacto que fabrica (`context` a secas colisionaría con el contexto
|
|
|
GL de `$scene`).
|
|
|
|
|
|
`sound.test.ts` **no se toca por A-4**: construye siempre por la forma B.
|
|
|
|
|
|
### 16.6 A-5, cerrado — `gainScale` a la envolvente, y RC-5 reescrito
|
|
|
|
|
|
`play()` ya no escribe el master. `gainScale` multiplica el pico de la
|
|
|
envolvente de ESA reproducción (`synthesize`) o la ganancia del sample
|
|
|
(`playSample`), y **también la profundidad del AM** — si no, un earcon atenuado
|
|
|
sonaría relativamente más áspero: cambiaría el timbre en vez del volumen, que es
|
|
|
lo contrario de lo que significa una reducción.
|
|
|
|
|
|
**RC-5 se reescribe, y es legítimo**: un contrato de regresión protege
|
|
|
comportamiento, no defectos, y lo que pineaba era _«la ganancia se fija ANTES de
|
|
|
que la síntesis reviente»_ — un orden que sólo importaba porque el valor iba a
|
|
|
un nodo compartido. La cobertura no se pierde, se coloca:
|
|
|
|
|
|
| Dónde | Qué pinea ahora |
|
|
|
| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
|
| `sound-port.test.ts` (ya existía) | el canal resuelve el NIVEL y lo entrega como `gainScale` |
|
|
|
| `sound.test.ts` (reescrito) | la reducción es por señal: **el master no se mueve** |
|
|
|
| `engine-sound.test.ts` (nuevo, con un contexto falso que sí construye el grafo) | el pico de la envolvente es `gain × gainScale` y el master conserva su valor de política · el AM escala con el nivel |
|
|
|
|
|
|
Los dos guards se verificaron **viéndolos fallar**: reintroduciendo la escritura
|
|
|
al master, caen `sound.test.ts` y `engine-sound.test.ts`.
|