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-redesign.md

217 lines
19 KiB

This file contains ambiguous Unicode characters!

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

# PLAN — Rediseño del sonido: `$sound` como ORQUESTADOR
> **Tipo**: plan de diseño + ejecución por fases (process — efímero, NO fuente de verdad).
> **Fecha**: 2026-07-31 · **Estado**: **GATE FIRMADO (D-SR.1…D-SR.12) · Entrega 1 en curso**.
> La firma: el usuario aprobó el plan completo el 2026-07-31, con D-SR.4 y D-SR.7
> respondidas explícitamente (transporte provisto por el servicio · dos entregas).
> **Kickoff para sesión nueva**: *"Lee `docs/process/PLAN-sound-redesign.md` y
> continúa por la fase abierta — el gate YA está firmado, no se re-presenta."*
---
## 1. El mandato (2026-07-31)
El usuario rechazó **de raíz** el gate del reproductor (D-AP.1…13,
[`PLAN-audio-player.md`](./PLAN-audio-player.md), suspendido): el sistema de
sonido es un frankenstein — nació de los diseños de otros módulos (sema primero,
media-player después) y se parchea en función de lo que ya había. Nunca partió de
su verdadera función: **orquestador y gestor de TODOS los aspectos del sonido en
el framework**, al nivel de `motion` / `timers` / `scene`. Los módulos que usan
sonido (players, semántica, estudio) **usan el servicio y se adaptan a su
filosofía, no al revés**.
Método mandado: **clean-room** — el diseño parte de la función, sin que los
consumidores actuales lo muevan; ellos se adaptan después.
**Qué se conserva y qué cae.** Los HECHOS medidos no se repudian: la invariante
de UN `AudioContext` por documento (medida en Chrome en los dos modos de
arranque), `decode` sin segundo contexto, los hallazgos del player (H-1, H-9,
H-10/G-5, los gaps G-1/G-2/G-3 del `Slider`). Lo que cae es **doctrina**:
`PLAN-sound-engine.md` §16.1 («la voz es del art, calibrada para sema; partir en
dos cuando llegue un segundo consumidor») queda **REVOCADA** — la calibración es
DATO del consumidor y el art es el orquestador desde ya; y el planteamiento del
player («el player decide cuándo engancharse») se invierte: el player consume el
transporte que el servicio provee.
## 2. La función — los 7 dominios (clean-room)
1. **Sustrato** — el `AudioContext` único: creación, unlock por gesto,
suspend/resume, SSR no-op, costuras de test, disposal. *(Existe y está
verificado — se conserva.)*
2. **Mezcla** — master → **buses** `ui` (earcons/semántica) y `content` (la
obra). Palancas por bus: gain, mute, retenciones de duck (refcount, gana la
más fuerte). Invariante estructural: `prefs.sound` gobierna SOLO `ui`; el
volumen de la obra es de la fuente + política del bus `content`.
3. **Voces** — la síntesis es MAQUINARIA del servicio; la **calibración es DATO
del consumidor** (espejo de motion: los consumidores registran presets, el
motor los ejecuta). `registerVoice(name, spec)`; sema registra la suya. Las 4
constantes perceptivas (quinta 1.5×/0.3 · clamps 15 %/40 % · ±400 cents ·
mapeo AM) dejan de ser del art. Samples/`preload`/caché y `decode` se
conservan como puertas.
4. **Ciudadanía de media** — las fuentes de contenido pasan por el servicio: el
transporte (play/pause/seek/volume/rate/snapshot/subscribe — la forma exacta
del puerto `MediaProvider` del player) lo PROVEE el servicio (D-SR.4), que
así sabe qué suena, aplica foco, proyecta MediaSession y gobierna el ducking.
5. **Política / arbitraje** — foco entre fuentes (`mixed` / `exclusive` /
`duck`, modelo AVAudioSession/AudioFocus) y la relación UI↔contenido: «la UI
calla mientras suena la obra» (incongruencia, D.7) pasa de parche de cascada
a **relación entre buses** (opt-in). Sema conserva su doctrina (firmas,
tunings, cascada, migración de significado con `off`).
6. **Percepción** — `decode` (existe); analyser por bus/fuente y picos para
`Waveform` = v2 con disposición escrita.
7. **SO** — MediaSession es singleton del documento → vive en el orquestador
(proyecta la fuente ACTIVA); los players aportan metadata.
Fuera, con disposición: spatial · cadenas DSP · grabación/captura · MIDI. El
háptico NO entra (otro canal, D.8 — asimetría ya documentada).
## 3. Referencias del sector
Modelos (conocimiento estable; el cross-check API-fino se hace al abrir la
Entrega 2, que es donde esas APIs muerden):
| Modelo | Aporta |
| --- | --- |
| iOS `AVAudioSession` | UNA autoridad por app: categorías, mixWithOthers/duckOthers, interrupciones |
| Android AudioFocus | las fuentes PIDEN foco (`GAIN`/`TRANSIENT`/`MAY_DUCK`); el árbitro aplica pérdidas |
| FMOD / Wwise | jerarquía de buses (master → music/sfx/voice), ducking entre buses |
| Howler.js | el singleton web: master volume/mute, autoUnlock, un contexto |
| Tone.js | contexto compartido + `Destination` |
| Plataforma web | cap de contextos, unlock por gesto, MediaSession API, trampa `createMediaElementSource` (irreversible; CORS → mudo) |
Ninguna referencia web une mezcla por buses + foco + MediaSession + voces
semánticas bajo una autoridad. Esa unión ES «gestor de todos los aspectos del
sonido» — superación, no paridad.
## 4. La filosofía, en espejo de los arts hermanos
- Como `timers`: **nadie** hace `new AudioContext()` ni Web Audio crudo fuera
del art; todo pasa por `uix.sound`.
- Como `motion`: el motor ejecuta, los consumidores REGISTRAN sus datos
(presets ⇒ voces). Sema deja de estar horneada dentro del art.
- Como `scene`: la ciudadanía (unlock, visibilidad, presupuesto, degradación,
teardown) se hace UNA vez y se hereda.
- Contrato de arts: `Engine*` sin runas · puertos `SoundDom`/`SoundTimers` ·
**diagnostics con catálogo tipado** (se adopta ahora — la decisión «espejo de
scene, sin diagnostics» se revierte, declarado en D-SR.9).
## 5. La API (v2)
```ts
const sound = createEngineSound({ dom, timers, logger, audioContextFactory, fetcher, masterGain });
// Sustrato (se conserva)
sound.prime(); sound.suspend(); sound.resume(); sound.dispose();
sound.state; sound.context;
// Mezcla (nuevo)
sound.master.setGain(v); // política de documento (antes setMasterGain)
sound.bus('ui').setGain(v);
sound.bus('ui').setMuted(true);
const hold = sound.bus('ui').duck(0.2); // refcount; gana la más fuerte; hold.release()
// Voces (nuevo)
sound.registerVoice('sema', voiceSpec); // calibración = dato del consumidor
await sound.play(sig, { bus: 'ui', voice: 'sema', gainScale });
// Muestras / percepción (se conserva)
await sound.preload(urls); await sound.decode(bytes);
// Ciudadanía de media (Entrega 2)
const media = sound.media(el, { metadata, focus: 'may-duck' });
// → implementa el puerto MediaProvider; foco mixed/exclusive/duck;
// duckUiWhileContent opt-in; MediaSession de la fuente activa; el grafo NO se
// engancha por defecto (trampa CORS declarada); visibilidad content-aware
// (jamás suspender el contexto con obra sonando — E-3, ahora estructural).
```
Sema tras la adaptación: resuelve firma + nivel (doctrina intacta) y llama
`play(sig, { bus: 'ui', voice: 'sema', gainScale })`. Nada más.
## 6. Decisiones — FIRMADAS 2026-07-31
| ID | Decisión firmada |
| --- | --- |
| D-SR.1 | `$sound` = EL orquestador (los 7 dominios). §16.1 revocada. Nombre/alias se quedan |
| D-SR.2 | Buses `master → ui + content` (set fijo v1); gain/mute/duck por bus; `prefs.sound` ↔ `ui` SOLO |
| D-SR.3 | Voces registradas; default del servicio = calibración actual (cero cambio audible); sema registra la suya encima |
| D-SR.4 | **El servicio PROVEE el transporte** (el adaptador nativo se muda al servicio; el player consume el handle). El elemento DOM sigue siendo del componente; grafo no enganchado por defecto |
| D-SR.5 | Foco default `mixed`; `exclusive`/`duck` config; `duckUiWhileContent` opt-in |
| D-SR.6 | MediaSession del servicio (fuente activa, opt-in con metadata) |
| D-SR.7 | **Dos entregas**: E1 = núcleo (F0–F2) con escucha A/B antes de seguir; E2 = media + MediaSession (F3–F4); F5 repartido |
| D-SR.8 | Sin `ActiveSound` v1; handles con `subscribe`; disposición escrita |
| D-SR.9 | Diagnostics contrato completo de arts (revierte «espejo de scene») |
| D-SR.10 | Player suspendido; re-plan posterior sobre el servicio (hereda hechos D-AP) |
| D-SR.11 | `prime/play/preload/decode/suspend/resume/state/context` se conservan; `setMasterGain` → `master.setGain` con barrido en el mismo pase (sin shim); `play` gana `{bus, voice}` con defaults que preservan el comportamiento |
| D-SR.12 | `sounds.ts` de sema intacto: `SOUND_LIBRARY` = recursos, `SOUND_TUNINGS` = canon (D.7) |
## 7. El terreno — destino de cada pieza
| Pieza | Destino |
| --- | --- |
| Gobierno de contexto + guard de 2.º contexto + `decode`/`preload`/caché/fallback | **Se conserva** (verificado) |
| `gainScale` en la envolvente (A-5) | **Se conserva** — es la separación política/reproducción que los buses generalizan |
| Las 4 constantes de la voz | **Se mudan** a `SoundVoice` (default = valores actuales; sema registra su voz) |
| `setMasterGain` | → `master.setGain` (barrido: art + tests + fake del port; ningún consumidor de producción fuera) |
| `autoSuspend` | Entrega 2: content-aware (no suspender con obra sonando) |
| `SoundChannel` | Adelgaza: resolve → `play({bus:'ui', voice:'sema', gainScale})`; registra la voz de sema al construirse (idempotente); unión A/B intacta |
| Asserts que cambian | `sound.test.ts` (harness de 1 nodo → nodos rastreados; `createGain` 1×→3×) y `sound-port.test.ts` (fake del engine + args de `play`) — **cambio de contrato declarado**, lo contrario del criterio value-preserving de la extracción, a propósito |
| `sound-e2e.test.ts` | Sin cambios: pinea osciladores/contextos, no el árbol de gains |
| `contracts.ts` fila `sound` | **Sin tocar en E1**: no pinea métodos y sus promesas siguen verdaderas; se revisará en E2 (el fichero además está contaminado por otra sesión) |
| Estudio `/temas/sema` | Sin cambios en E1 (no usa `setMasterGain`); se revisita en E2 |
| `/temas/orquesta` | Legal (forma B); deuda de esa demo |
## 8. Fases
| Fase | Contenido | Verify |
| --- | --- | --- |
| **F0 — Documento** ✅ | Este plan · `CONTINUE-sound-engine.md` reescrito (pivote) · cabecera de `PLAN-audio-player.md` (suspensión, ya hecha) | docs:check limpio en lo propio |
| **F1 — Motor v2 (E1)** ✅ **HECHA 2026-07-31** | Buses (master→ui/content, gain/mute/duck refcount, política pre-contexto) + voces (`registerVoice`, default = calibración histórica, aviso una vez por voz desconocida) + `play({bus,voice})` (defaults `ui`/default) + `master.setGain` (barrido de `setMasterGain`: art+tests+fake; ningún consumidor de producción fuera) + `diagnostics.ts` (catálogo tipado, D-SR.9); invariantes de contexto INTACTAS | ✅ suite del art **19/19** (14 originales — 2 asserts de contrato declarados: `createGain` 1×→3× y harness multi-nodo — + 5 nuevos de buses/voces) · ✅ `check` **73 = baseline exacta, 0 propios** |
| **F2 — Sema se adapta (E1)** ✅ **HECHA 2026-07-31** | `SEMA_SOUND_VOICE`(+NAME) en `sounds.ts` (tipo importado de `$sound`, dirección permitida) · el canal registra la voz al construirse (idempotente; en AMBAS formas A/B) y toca `play({bus:'ui', voice:'sema', gainScale})` · `sound-port.test.ts` (fake al contrato nuevo + pin de registro) · `sound.test.ts` (harness multi-nodo + `createGain` 3×, declarado) · e2e y `active-uix` SIN tocar · docs de la entrega: README del art reescrito (identidad orquestador) + fila/grafo de `arts/README.md` + `architecture/sema.md` §SoundChannel | ✅ **18 suites / 237 tests verdes** (sema completa + art + active-uix; e2e de contexto único intacto) · ✅ `check` 73/0 propios · ✅ **escucha A/B del usuario: «suena igual» (2026-07-31) — ENTREGA 1 CERRADA** |
| **F3 — Ciudadanía de media (E2)** ✅ **HECHA 2026-07-31** | `src/arts/sound/media.ts`: `sound.media(el, {focus?, metadata?})` → handle con la forma EXACTA del puerto `MediaProvider` (adaptador nativo mudado: flag `waiting`, `bufferedAhead`, clamps; `kind` por `tagName`, no `instanceof` — cross-realm-safe) · foco `mixed`/`exclusive`/`duck` (servicio + override por fuente; duck sobre `el.volume` con `snapshot().volume` = volumen del DUEÑO) · `duckUiWhileContent` (hold refcount sobre el bus `ui`) · visibilidad content-aware (fuente sonando veta el suspend) · `attach()` opt-in al bus `content` (irreversible, CORS declarado) · manager perezoso (sin media no se paga) · cross-check MediaSession contra MDN hecho al abrir | ✅ 10 tests nuevos con elementos fake (transporte · exclusive · duck+restore · override · duckUi pre-contexto · visibilidad · attach al contexto COMPARTIDO · sesión · sin-metadata no toca · dispose) · ⚠️ la medición en Chrome del escenario mixto queda para el re-plan del player: **ninguna página consume `media()` aún** — no hay nada real que medir |
| **F4 — MediaSession (E2)** ✅ **HECHA 2026-07-31** (dentro de `media.ts`) | Proyección de la fuente ACTIVA (opt-in por `metadata`): `MediaMetadata` + `playbackState` + `play`/`pause`/`seekto` (un try por acción — un nombre no soportado no tumba al resto) + `setPositionState` en timeupdate/duration/rate · liberación en destroy/dispose (metadata null, handlers null) · promoción a otra fuente sonante con metadata al morir la activa · feature-detected, todo absorbido con diagnóstico | ✅ tests con `navigator.mediaSession` fake (proyección, tecla pause → handle, liberación, opt-in) · ⚠️ prueba manual de teclas de medios: necesita una página consumidora — llega con el re-plan del player |
| **F5 — Barrido + docs** ✅ **HECHA 2026-07-31 (con 2 exclusiones declaradas)** | `defineEngineSound(options)` acepta la rodaja de política (`EngineSoundPolicyOptions`) · README del art (media citizenship) · `arts/README.md` · docblock del motor · **`contracts.ts` NO tocado** (no pinea métodos, sus promesas siguen verdaderas, y el fichero está contaminado por otra sesión) · **estudio sin cambios** (no usa la superficie nueva) | ✅ **27 suites / 301 tests verdes** (sema+sound+active-uix+active-app) · ✅ `check` **73 = baseline, 0 propios** (verificado con el patrón `\\\\` correcto — el filtro anterior no casaba, la trampa documentada) |
| Después (iniciativa aparte) | Re-plan del reproductor sobre el servicio | su propio gate |
Método no-cascada. Sin commits salvo petición expresa (árbol compartido).
## 9. Auditoría post-entrega (2026-07-31) — AU-1…AU-9, TODAS RESUELTAS
Auditoría clean-room de las dos entregas (pedida por el usuario; orden de cierre
firmado con «ok»). 9 hallazgos, ninguno invalidó el rediseño; resoluciones:
| AU | Hallazgo | Resolución |
| --- | --- | --- |
| **AU-1** | El foco `duck` era starter-céntrico: una fuente que arrancaba DESPUÉS del ducker no quedaba atenuada; divergía del modelo de referencia (duckOthers persistente) sin documentar | ✅ **Semántica reescrita y FIRMADA: estado derivado del conjunto sonante** (`refreshDucking`): mientras suene un ducker, toda otra fuente sonante queda atenuada — llegadas tardías incluidas; entre duckers simultáneos, el más reciente retiene la palabra. `exclusive` sigue siendo acción del starter (sin re-reclamo, documentado). **Test nacido en ROJO** contra el código viejo y verde tras el fix |
| **AU-2** | `media()`/`attach()` sin guard post-dispose: attach podía abrir un `AudioContext` HUÉRFANO en un engine disposed; `preload()` (preexistente v1) igual de desguardado | ✅ Guard `disposed` en `ensureContext()` (cierra TODAS las vías) + en `preload()` · `dispose()` limpia el manager FUERA del guard de idempotencia (un segundo dispose libera un registro post-dispose) · comportamiento post-dispose documentado en `types.ts` · test: attach post-dispose → `null`, factory jamás llamada |
| **AU-3** | Doble registro del mismo elemento: listeners duplicados + auto-duck | ✅ `byElement`: re-registrar RETIRA la inscripción obsoleta (un remonte nunca apila); el elemento destruido sale de la mezcla al volumen del DUEÑO. Test |
| **AU-4** | La invariante «prefs.sound ↔ bus ui» enunciada como mecanismo, sin productor (la reducción sigue siendo `gainScale` del canal) | ✅ **Texto corregido** (README del art + `sema.md`), patrón A-2: lo garantizado por construcción es el NEGATIVO (nada de UI toca content); el bus `ui` es la palanca que una app PUEDE cablear. El cableado directo queda como patrón de app, no del framework |
| **AU-5** | Duplicación estructural handle↔puerto `MediaProvider` sin guard de deriva hasta el re-plan | ✅ `soma/components/media-player/media-provider-drift.test.ts` (lado soma — la dirección de import permitida): `Exact` en snapshot+eventos, asignabilidad unidireccional del handle. Falla `check` si derivan |
| **AU-6** | Verifies prometidos sin ejecutar: `docs:check` (F0) y `lint` | ✅ Medidos: `docs:check` **0 errores/555 docs** · prettier fallaba en 5 ficheros míos → formateados, `--check` limpio |
| **AU-7** | Verify de F3 reescrito al marcarlo (cross-check solo MediaSession) · promesa «media tree-shakeable + medir» incumplida | ✅ Declarado aquí: AVAudioSession/AudioFocus quedan como modelos de diseño (no hay código contra ellas) · media es alcanzable estáticamente A PROPÓSITO (es la puerta del servicio) · **bundle MEDIDO**: art completo **16,2 KB min · 5,6 KB gzip** (era 2,2 gz; la ciudadanía entera cuesta ~3,4 KB gz). La fila de riesgo de §10 queda enmendada por esta |
| **AU-8** | Guards nuevos sin ver fallar (regla dura del hilo) | ✅ Tres verificados: persistencia del duck (test nacido rojo) · veto de visibilidad (mutación → rojo → revertida) · `duckUiWhileContent` (mutación → rojo → revertida) |
| **AU-9** | Declaraciones a medias: standalone sin acceso a política; acciones MediaSession omitidas sin disposición | ✅ Disposiciones escritas en el README: standalone se cablea cuando exista el primer consumidor (no crecer superficie sin uso); `stop`/`seekforward`/`seekbackward`/tracks llegan con el re-plan del player si su UX las pide |
Notas menores registradas sin acción (deliberado): fallbacks defensivos
inalcanzables en `play()`/`attach()` · carrera teórica de `volumechange` async
(irrelevante en fakes, ventana microscópica en navegador) · `attach()`+
`autoSuspend` puede silenciar el elemento adjunto con pestaña oculta (combinación
opt-in+opt-in, se documentará si aparece el caso) · `preload()` puede pender en
`resume()` fuera de gesto (preexistente v1, hoy además guardado por disposed) ·
`exclusive` no re-arranca a los desplazados (modelo AudioFocus, documentado).
**Gate tras la auditoría**: 28 suites / **305 tests** verdes (incl. drift guard)
· `check` **73 = baseline, 0 propios** (verificado con patrón `\\\\`; los 4 de
`uix\soma` que caza el filtro ampliado son de `file-upload`, ajenos y dentro de
la baseline) · `docs:check` 0 · prettier limpio en lo propio.
## 10. Riesgos
| Riesgo | Acotación |
| --- | --- |
| Regresión perceptiva (los tests no oyen) | Voz default = calibración actual → cero cambio audible en E1; escucha A/B del usuario = verify de F2 |
| Churn de tests | Solo asserts cuyo contrato cambió, declarados fichero a fichero (§7) |
| Trampa `createMediaElementSource` | El grafo no se engancha por defecto; attach opt-in con limitación declarada |
| Bundle | Núcleo 2,2 KB gz hoy; media en módulo tree-shakeable; medir en F5 |
| Sobre-alcance | 7 dominios diseñados, implementación escalonada; percepción avanzada v2 con disposición |

Powered by TurnKey Linux.