19 KiB
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.mdy 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, 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)
- Sustrato — el
AudioContextúnico: creación, unlock por gesto, suspend/resume, SSR no-op, costuras de test, disposal. (Existe y está verificado — se conserva.) - Mezcla — master → buses
ui(earcons/semántica) ycontent(la obra). Palancas por bus: gain, mute, retenciones de duck (refcount, gana la más fuerte). Invariante estructural:prefs.soundgobierna SOLOui; el volumen de la obra es de la fuente + política del buscontent. - 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é ydecodese conservan como puertas. - 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
MediaProviderdel player) lo PROVEE el servicio (D-SR.4), que así sabe qué suena, aplica foco, proyecta MediaSession y gobierna el ducking. - 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 conoff). - Percepción —
decode(existe); analyser por bus/fuente y picos paraWaveform= v2 con disposición escrita. - 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 hacenew AudioContext()ni Web Audio crudo fuera del art; todo pasa poruix.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 · puertosSoundDom/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)
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 |