refactor(sound): extraer el motor Web Audio de sema al art `$sound`

`sema/chans/sound.ts` tenía 407 líneas de las que ~255 (≈63%) eran maquinaria
Web Audio —ciclo de vida del AudioContext, síntesis, samples, desbloqueo por
gesto— mezclada con doctrina perceptiva. Deuda arrastrada, con tres síntomas
medidos: el import era estático, así que toda app con sema metía el
sintetizador en el bundle aunque el sonido estuviera apagado (que es el
default); faltaba ciudadanía que `$scene` ya resuelve; y la costura de
inyección (`audioContextFactory`) llevaba ahí sin usar desde el principio.

Mismo movimiento que `$motion` hizo desde eidos: el art se lleva el RUNTIME, la
capa conserva sus DATOS y su doctrina.

- `$sound` / `EngineSound`: UN AudioContext por documento (los navegadores los
  limitan y el gesto de desbloqueo es por contexto), síntesis de earcon,
  samples con caché y fallback a síntesis, `autoSuspend` OPT-IN —suspender con
  la pestaña oculta es correcto para earcons y erróneo para contenido, así que
  es decisión de quien compone— y aviso cuando un segundo contexto va vivo.
  Puertos `SoundDom` / `SoundTimers` inyectados; no importa ningún otro art ni
  nada de `$uix/sema`, que es la prueba objetiva del corte.
- `SoundChannel`: 407 → 136 líneas. Solo doctrina: el gate de `prepare`, la
  política de reducción y el reparto «el canal resuelve el NIVEL, el motor
  aplica la ganancia». Sema no gana ni un import: recibe el motor por puerto.
- `uix.sound` en standalone y attach con `ownsSound` (idioma ya shipped:
  `ownsMotion` / `ownsScene`), fila `sound` en la tabla ejecutable
  `contracts.ts`, y `defineEngineSound()` para el camino de app.
- Tests nuevos: `engine-sound.test.ts` (11), `sound-port.test.ts` (guard de
  deriva de tipos + la regla de propiedad) y `sound-e2e.test.ts`, que recorre
  `emit -> cascada -> canal -> art -> grafo real`: el camino que las 16 suites
  previas no cubrían porque paraban en canales falsos.

Sin `diagnostics.ts` ni `errors.ts`, y es decisión: espejo de `$scene`, aquí
todo fallo es degradación documentada, no error de programador.

Verificación: 17 suites / 198 tests · `check` en la baseline exacta (73
errores, 0 propios) · `sound.test.ts` verde SIN tocar un solo assert, que era
el criterio de que el movimiento fue value-preserving.

Planes: `PLAN-sound-engine.md` (completo, con el registro de la revisión
adversarial E-1..E-7), `PLAN-audio-player.md` (aparcado tras el análisis del
reproductor, con sus correcciones en cabecera) y `CONTINUE-sound-engine.md`
(handoff).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 2 months ago
parent 37061a7371
commit 6920684d09

@ -139,7 +139,8 @@ src/uix/sema/
└── chans/
├── types.ts Channel interface — handle(signal, effective)
├── visual.ts VisualChannel (data-event projection + hold)
├── sound.ts SoundChannel (Web Audio earcons + sample playback)
├── sound.ts SoundChannel (gate + reduction policy; the Web Audio
│ runtime lives in the `$sound` art, injected)
└── haptic.ts HapticChannel (Vibration API + categorical kinds)
```
@ -595,29 +596,55 @@ hand-written examples this section used to show targeted `[data-toast-root]`
{ selector: semaSelector(dialogMorfo, 'content', { eventNamePrefix: 'close-' }), sound: { contour: 'descending' } }
```
### SoundChannel
Short earcons synthesized via the Web Audio API from `effective.sound`
(pitch / centroid / roughness / attack / decay / duration / contour / gain).
Details:
- A single `AudioContext` with a master `GainNode` per engine.
- **Prepare-time priming**, no constructor side effect. The channel creates +
resumes the `AudioContext` in `prepare()` when the signal admits `sound`,
synchronously inside the user gesture.
- After creating the context, it registers a `click` / `touchstart` /
`keydown` listener via the injected DOM surface
(`ActiveDom.listen(ActiveDom.getDocument(), ...)`) to re-resume after
passive suspends (tab switch, etc.). If the channel never prepares an
audible signal, it installs no global listeners.
### SoundChannel — doctrine here, machine in `$sound`
> **The Web Audio machinery does NOT live in sema** (since 2026-07-30). The
> context lifecycle, the synthesis graph, the unlock-on-gesture and the sample
> path were extracted to the art [`$sound`](../../src/arts/sound/README.md)
> (`EngineSound`) — they were ~63% of a 407-line "channel" and are machinery,
> not perceptual doctrine. Same move `$motion` made out of eidos, for the same
> reason and with the same result: the art owns the RUNTIME, the layer keeps its
> DATA and doctrine.
What the channel keeps — all of it doctrine:
- the `prepare` gate (does this signal admit sound at all?),
- the per-channel reduction policy (BK-REDUCTIONS): `off` silences (meaning
migrates via `SEMA_MIGRATION.sound` → presence / live region), `reduce`
attenuates,
- and the division of labour: **the channel resolves the LEVEL, the engine
applies the gain**.
Everything else — `SOUND_LIBRARY`, `SOUND_TUNINGS`, the gesture resolvers, the
cascade — is unchanged and still sema's.
**How it reaches the engine.** `EngineSemantic` takes a `soundEngine` option and
forwards it; `ActiveUix` creates the engine and injects it as `uix.sound`. Sema
**imports no art**: it receives a structural port, the `MotionDom` / `SceneDom`
pattern. Without an injected engine the channel creates a private one and owns
its lifecycle — *whoever creates, disposes*; a shared engine is never closed by
a consumer.
**Why one engine matters.** Browsers cap concurrent `AudioContext`s and the
autoplay unlock gesture is per-context, so a second context leaves one of the
two mute. An app that also plays content (a media player, a waveform) must take
`uix.sound` — or pass `soundEngine` to its own `EngineSemantic` — instead of
opening its own. The art warns when a second context goes live.
Behaviour, unchanged by the extraction:
- **Prepare-time priming**, no constructor side effect: the context is created +
resumed in `prepare()` when the signal admits `sound`, synchronously inside
the user gesture.
- The unlock listener (`pointerdown` / `mousedown` / `click` / `touchstart` /
`keydown`) is registered only AFTER the context exists, through the injected
DOM surface. An engine that never plays installs no global listeners.
- Synthesis: two oscillators (sine + a fifth) → biquad lowpass (centroid) →
ADSR-lite envelope. If `roughness > 0.2`, a fast AM modulator.
- `contour` (`flat` / `ascending` / `descending` / `arc` / `bell`) is applied
via `osc.detune`.
- If the signature carries a `sampleUrl`, it plays the sample (with an
`AudioBuffer` cache) instead of synthesizing.
- Any failure (no AudioContext, decode failure) is absorbed — sema is
ornamental.
ADSR-lite envelope; `roughness > 0.2` adds a fast AM modulator; `contour`
(`flat` / `ascending` / `descending` / `arc` / `bell`) rides `osc.detune`.
- A signature carrying `sampleUrl` plays the sample (with an `AudioBuffer`
cache) and **falls back to synthesis** on fetch / decode failure.
- Any failure is absorbed — sema is ornamental.
The prepare-time priming pattern applies in general to any channel whose
backend has a "first time must happen inside a gesture" restriction: audio,

@ -0,0 +1,89 @@
# CONTINUE — motor de sonido + reproductor
> **Kickoff para la sesión siguiente**: *"Lee `docs/process/CONTINUE-sound-engine.md`
> y sigue por donde toque."*
> **Fecha**: 2026-07-30 · Rama `alpha-0.1-sec-dom`.
Handoff corto. La verdad detallada vive en los dos planes; esto solo dice **en
qué punto está cada cosa y qué trampas evitar**.
---
## Estado
| Iniciativa | Estado |
| --- | --- |
| [`PLAN-sound-engine.md`](./PLAN-sound-engine.md) | ✅ **COMPLETA** — F0…F4 hechas. Solo queda F5 (desbloquear el reproductor), que es decisión de sesión |
| [`PLAN-audio-player.md`](./PLAN-audio-player.md) | ⏸️ **APARCADO** — D-AP.1…D-AP.12 sin firmar. Su cabecera lleva **5 correcciones pendientes de incorporar** antes de presentar nada |
## Qué se construyó
**`$sound` / `EngineSound`** ([`src/arts/sound/`](../../src/arts/sound/README.md)) — el
runtime Web Audio extraído de `sema/chans/sound.ts`, que tenía ~63% de máquina
mezclada con doctrina. Un `AudioContext` por documento, síntesis de earcon,
samples con caché y fallback, desbloqueo por gesto, `autoSuspend` opt-in y aviso
al segundo contexto vivo.
- `SoundChannel`: **407 → 136 líneas**, solo doctrina (gate de `prepare`,
política de reducción, «el canal resuelve el NIVEL, el motor aplica la
ganancia»).
- Sema **no importa el art**: lo recibe por puerto inyectado.
- `uix.sound` en ambos modos de arranque, con `ownsSound` (idioma ya shipped:
`ownsMotion` / `ownsScene`), fila en la tabla ejecutable `contracts.ts`, y
`defineEngineSound()` para attach.
**Gate al cerrar**: 17 suites / 198 tests · `check` en la baseline exacta
(73 errores, 0 propios) · `sound.test.ts` verde **sin tocar un solo assert**.
## Lo que NO está verificado (y hay que verificar)
1. **La invariante de contexto único en un navegador real.** Hoy **ninguna**
página enchufa `soundEngine` a su propio `EngineSemantic` — el estudio de
sema incluido ([`temas/sema/_lib/audition.ts:35`](../../web/routes/temas/sema/_lib/audition.ts)
monta el suyo con `sound: true`), así que esa página abre un **segundo
contexto**. Con el aviso nuevo ya lo dirá por consola: **abrir `/temas/sema`
y mirar el warn es la primera comprobación de mañana.** Arreglo natural:
pasarle `soundEngine: uix.sound`.
2. **La escucha comparativa fina.** El usuario confirmó que suena, pero no se
hizo un A/B earcon a earcon contra el comportamiento previo.
## Trampas de este repo (verificadas a base de perder tiempo)
- **NO arrancar un dev server si el del usuario está vivo.** `npm run dev` es
`vite dev --force`: reescribe `node_modules/.vite/deps` por debajo del que
corre → dos runtimes de Svelte → la página deja de hidratar
(`lifecycle_outside_component`) → parece regresión de audio y no lo es.
Pasó el 2026-07-30. Verificar con **tests deterministas** (dobles de
`AudioContext`), no levantando servidores.
Recuperación (PowerShell): parar node →
`Remove-Item -Recurse -Force node_modules/.vite/deps` → `npm run dev` →
**Ctrl+Shift+R**.
- **El panel del navegador no pinta** en este entorno (viewport 0×0);
`javascript_tool` sí funciona.
- **Filtrar `npm run check` con cuidado**: las rutas llevan `\\` doble, así que
un patrón `sema.chans` NO casa con `sema\\chans` y da falsos «0 errores
propios». Ocurrió.
- **El árbol está compartido con otras sesiones** (palabras, menubar,
dropdown-menu, blocks/cta, alpha…). `git reset -q` + `add` solo lo propio,
siempre en un bloque atómico.
## Ajenos y preexistentes detectados (NO tocados)
- `contracts.test.ts` 3 rojos: `aura`, `menubar`, `radio-group`.
- `docs:check` 1 error: `eidos/components/callout/README.md:23` dice «8 roles»,
son 9.
- `Board.svelte:42` warn `binding_property_non_reactive` — inofensivo ahí
(lectura imperativa); se arregla con `let els = $state({})`.
- `temas/sema/_lib/audition.ts` tiene 2 errores de tipos preexistentes.
## Siguientes pasos posibles
1. **F5** — desbloquear el reproductor y reescribir su **D-AP.11** contra el art
ya existente (su premisa —«no hay motor»— era falsa).
2. **Cerrar la invariante**: pasar `soundEngine` al `EngineSemantic` del estudio
y comprobar que el warn desaparece.
3. **Retomar el reproductor** por su F0: firmar D-AP.\* con las 5 correcciones
de la cabecera ya incorporadas (segmentos, G-2 bloqueante, ducking…).
**No registrado en `next-features.md` a propósito**: ese fichero está modificado
por otra sesión y no se toca desde aquí.

@ -0,0 +1,377 @@
# PLAN — Reproductor de sonido (`AudioPlayer`)
> **Tipo**: plan de creación por fases (process — efímero, NO fuente de verdad).
> **Fecha**: 2026-07-30 · **Estado**: **⏸️ APARCADO 2026-07-30 — bloqueado por
> [`PLAN-sound-engine.md`](./PLAN-sound-engine.md)**. Decisiones D-AP.1…D-AP.10
> SIN FIRMAR. **No arrancar F0 hasta que el art `$sound` exista** (su F5 desbloquea
> éste).
> **Kickoff para sesión nueva**: *"Lee `docs/process/PLAN-sound-engine.md` primero;
> este plan está aparcado detrás de él."*
>
> ### ⚠️ Correcciones pendientes de incorporar (sesión 2026-07-30, posteriores al cuerpo)
>
> El cuerpo de abajo NO las refleja todavía. Al desaparcar, incorporarlas ANTES
> de presentar nada:
>
> 1. **Faltaba la reproducción de SEGMENTOS entera.** No está en la matriz, ni en
> el contrato, ni en las variantes. Y **Vidstack SÍ los tiene**
> (`clipStartTime` / `clipEndTime`, con los que calcula el rango *seekable*),
> así que es **paridad pendiente, no superación**. Diseño acordado: `clip =
> { start, end, loop? }` en el provider · capa de proyección absoluto↔relativo
> (dominio del slider `0…end−start`, `timeBase: 'clip' | 'source'` en `Time`,
> misma base en `aria-valuetext`) · clamp en el provider, no en el puerto ·
> `commit-complete` dispara en el fin del clip (mismo evento, frontera movida)
> · **con `loop` NO hay `commit-complete`** (un bucle esperado no es ocurrencia
> — regla B.5) · limitación declarada: `timeupdate` ~4/s ⇒ punto de bucle
> impreciso sin Web Audio · riesgo a probar: bug abierto de clip en Chrome/
> Safari móvil (vidstack#1195). `chapters` = v2 · `regions` = descartado.
> 2. **G-2 (pista secundaria del `Slider`) sube a BLOQUEANTE de v1**: ya tiene
> tres consumidores — buffer + ventana de clip + capítulos.
> 3. **D-AP.11 queda obsoleta y se reescribe contra el art.** Su premisa («no hay
> motor, ¿dónde lo ponemos?») era falsa: el ecosistema ya tenía un motor Web
> Audio de 407 líneas dentro de `sema/chans/sound.ts`. Cuando este plan se
> desaparque, `$sound` ya existirá y el player solo declara si lo consume.
> 4. **Dos invariantes a escribir en el contrato**: (a) **`prefs.sound` nunca toca
> el volumen del contenido** — gobierna el sonido de UI (sema); un reproductor
> de sonido es el primer componente del catálogo donde «sonido» significa dos
> cosas. (b) El silencio de UI en audio (D-AP.7) debe alcanzar al **`Slider`
> compuesto**: existe `resolveSliderDragSound` y arrastrar el scrubber
> sonorizaría por encima del contenido — verificar con stamp real y, si
> dispara, la regla es `[data-media-player][data-media='audio'] [data-slider]`.
> 5. **D-AP.7 se reescribe como *ducking***: con un motor dueño del earcon y del
> contenido, deja de ser regla de cascada y pasa a capacidad del motor.
>
> **Antes de escribir una línea de código**: presentar al usuario las decisiones
> de §4 y obtener firma. Lo de aquí son **propuestas razonadas, no decisiones
> tomadas** — regla A32 (`component-guide.md`) + §0.5 de
> [`component-audit.md`](../guides/component-audit.md) ("Get the user's sign-off
> on scope before writing any code").
>
> **Precondición cumplida**: corpus doctrinal leído completo antes de auditar
> (regla dura `feedback_read_full_doctrine_before_auditing`): README · overview ·
> active-architecture · CANON · morfo · sema · soma(+architecture) · eidos ·
> active-uix · active-app · packs · blocks · agent · TSC · recipe-contract ·
> vocabularies · component-guide · completion-checklist · component-audit ·
> demo-authoring · theming(reference/guide/notes/motion/motion-guide/channels/
> gradient-finish) · los 7 RFCs · book-deviations · design-text-effects ·
> design-chat-block · glossary · comparison · authoring · testing-and-tooling ·
> next-features · decisions · book-map · guía histórica · veredictos/_system/
> _naming/_cierre de la auditoría · `eidos/components/README.md` · `arts/README.md`.
---
## 0. Contexto — qué existe hoy y qué NO
El ecosistema **ya tiene un reproductor**: `media-player`, genérico
`<video>` + `<audio>`, cerrado como PASS en la matriz de aceptación.
| Capa | Estado | Evidencia |
| --- | --- | --- |
| morfo | 534 líneas · 17 partes · 12 eventos · `expression: 'pack'` · `apg: 'none — …'` | [`morfo/components/media-player.ts`](../../src/uix/morfo/components/media-player.ts) |
| soma | provider request-driven + **puerto `MediaProvider`** + adaptador nativo + 14 wrappers + test 9/9 | [`soma/components/media-player/`](../../src/uix/soma/components/media-player/) |
| sema | pack «voz de transporte contenida» (3 reglas) | [`sema/components/media-player.ts`](../../src/uix/sema/components/media-player.ts) |
| eidos | recipe *Lumière* (11.7 KB) + 17 wrappers + `DefaultControls` | [`eidos/components/media-player/`](../../src/uix/eidos/components/media-player/) |
| demo | 9 pestañas, grupo **Media** del sidebar | `web/routes/uix/components/media-player/+page.svelte` |
**El modo audio existe, pero es un reproductor de vídeo degenerado.** Todo el
soporte «audio» del catálogo son **tres reglas CSS**:
```
media-player.css:79 [data-media='audio'] [data-media-player-media] → height: 0
media-player.css:161 [data-media='audio'] [data-media-player-title] → position: static
media-player.css:191 [data-media='audio'] [data-media-player-controls] → position: static
```
Es decir: se colapsa el vídeo a cero y se desabsolutizan dos cajas. **No hay
nada de lo que hace que un reproductor de sonido sea un reproductor de sonido**:
identidad de la pieza (carátula · título · artista), waveform, velocidad de
reproducción visible, capítulos, formas compactas, integración con el SO.
### 0.1 Hallazgos de la auditoría del estado actual (evidencia leída)
| ID | Hallazgo | Evidencia | Consecuencia |
| --- | --- | --- | --- |
| **H-1** | **Regla sema probablemente muerta.** El pack construye su selector sobre `provider` (`semaSelector(morfo,'provider',{eventName:'commit-toggle-play'})` → `[data-media-player][data-event='commit-toggle-play']`) pero el evento estampa en **su propio botón** (`target: v.partRef('play-button')`). | `sema/components/media-player.ts:23` vs `morfo/…/media-player.ts:95` | La firma de play/pause nunca se aplicaría. **Verificar en F0 con stamp real en navegador**; si se confirma, es fix de una línea (`onPlayButton`). |
| **H-2** | `SettingsButton` y `Captions` **declarados e inertes** ("render inertly until implemented", README soma:99-100). | `soma/…/README.md:96` | El menú velocidad/pista es *justo* lo que más pesa en audio (podcast). Deuda declarada que este plan recoge. |
| **H-3** | **`Slider` no tiene pista secundaria.** Partes: `Provider · Range · Thumb · Tick`. El buffer del player es un `div` eidos-only que lee `--media-buffered`. | `morfo/components/slider.ts` | Un waveform-seek necesita el mismo hueco. Gap de framework (§8). |
| **H-4** | **`Slider` no declara `aria-valuetext`.** Solo `aria-valuemin/max/now`. | `morfo/components/slider.ts:107-109` | El lector de pantalla anuncia *«127»*, no *«2:07 de 41:15»*. Fallo de nivel de referencia en el control MÁS importante del player. |
| **H-5** | **Formato de tiempo hecho a mano.** `formatTime()` con `padStart` local, fuera de `uix.format` / `$libs/days`. | `soma/…/media-player-provider.svelte.ts:34-40` | Contradice el eje de servicios (i18n · dígitos no arábigos · RTL). |
| **H-6** | **No hay `--media-progress`.** Se eliminó por código muerto; solo sobrevive `--media-buffered`. | README eidos:47 | Un waveform necesita el progreso como var continua otra vez (o su propio mecanismo). Decisión, no accidente. |
| **H-7** | **Cero integración MediaSession** en todo el repo. | `grep -rn mediaSession src/` = 0 | Sin controles de pantalla de bloqueo / teclas de medios del SO. |
| **H-8** | La carátula (`Poster`) es `position:absolute; inset:0; object-fit:cover` sobre una caja `16/9`. En audio esa caja **no existe** (media a `height:0`). | `media-player.css:85-93` | La carátula de un álbum no tiene hogar geométrico hoy. |
Ninguno de los ocho invalida el `media-player`: son exactamente la superficie
que un modo audio de nivel de referencia obliga a cerrar.
---
## 1. Qué es —de verdad— un reproductor de sonido
No es «un player sin imagen». Cambian tres cosas de raíz:
1. **La superficie deja de ser el contenido.** En vídeo el marco *es* la obra y
el chrome se esconde encima (scrim, auto-hide). En audio **no hay marco**: la
UI es lo único visible, así que pasa de *transparente* a *persistente*, y su
trabajo cambia de «no estorbar» a «identificar la pieza».
2. **La identidad sustituye al fotograma.** Carátula, título, artista/serie,
capítulo. Ninguna existe hoy más allá de un `Title` de una línea.
3. **La línea de tiempo se vuelve legible.** En vídeo el scrubber se previsualiza
con miniaturas; en audio la referencia del sector es la **onda** (SoundCloud,
wavesurfer) o los **capítulos** (podcast). La barra desnuda es el mínimo, no
el estándar.
Y aparecen tres afordancias que en vídeo son secundarias y aquí son de primera
fila: **velocidad** (podcast/curso), **±15/30 s** (ya existe como `SeekButton`)
y **continuidad con el SO** (pantalla de bloqueo, auriculares, coche).
---
## 2. Comparativa contra referencias (F-1.2 exige ≥3; aquí 8 + la plataforma)
Consultado 2026-07-30. La columna «UIX hoy» = `media-player` en `media="audio"`.
| Capacidad | Vidstack (`DefaultAudioLayout`) | Media Chrome (`<media-controller audio>`) | Plyr | Red Hat `rh-audio-player` | wavesurfer.js 7 | react-h5-audio-player | UIX hoy | Veredicto v1 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| play/pause · ±seek · mute · volumen · tiempo | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
| **Layout de audio propio** (no vídeo colapsado) | ✅ `DefaultAudioLayout` | ✅ atributo `audio` | ✅ preset audio | ✅ `layout` | n/a | ✅ | ❌ 3 reglas CSS | **implementar** |
| **Formas / tamaños** (full · compact · mini · barra) | ⚠️ `smallLayoutWhen` (2 tamaños) | ⚠️ composición manual | ❌ | ✅ full/compact/mini | n/a | ⚠️ | ❌ | **implementar** |
| **Identidad**: carátula · título · artista | ⚠️ `Title`+`Poster` | ⚠️ slots | ⚠️ | ✅ series/title/poster | ❌ | ✅ header | ⚠️ solo `Title` | **implementar** |
| **Velocidad de reproducción** (UI) | ✅ menú + `SpeedSlider` | ✅ `media-playback-rate-button` | ✅ settings.speed | ✅ | n/a | ❌ | ❌ (`setPlaybackRate` existe, sin UI) | **implementar** (cierra H-2) |
| **Waveform** | ❌ | ❌ | ❌ | ❌ | ✅ (el núcleo) | ❌ | ❌ | **componente propio** (D-AP.4) |
| **Capítulos** | ✅ `SliderChapters` + `ChapterTitle` + radio-group | ⚠️ vía cues | ✅ `markers` | ✅ transcript/cues | ✅ Regions | ❌ | ❌ | **diferir v2** |
| **Transcripción** | ⚠️ captions | ⚠️ | ❌ | ✅ `rh-transcript`+`rh-cue` | ❌ | ❌ | ❌ | **diferir v2** |
| **Cola / playlist** | ❌ | ❌ | ❌ | ❌ | ❌ | ⚠️ skip prev/next | ❌ | **fuera** (D-AP.6) |
| **MediaSession (SO)** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | **superación** (D-AP.5) |
| **Ganancia >100 %** | ✅ `AudioGainSlider` | ❌ | ❌ | ❌ | ⚠️ Envelope | ❌ | ❌ | **diferir v2** |
| **Loop / descarga** | ⚠️ | ⚠️ | ✅ | ⚠️ | n/a | ✅ loop | ⚠️ nativo | **loop v1 (prop), descarga = app** |
| **Teclado completo** | ✅ | ✅ | ✅ | ✅ | ⚠️ | ✅ | ✅ (9 teclas) | ✅ |
| **`aria-valuetext` hablado en el scrubber** | ✅ | ✅ | ✅ | ✅ | ❌ | ⚠️ | ❌ (H-4) | **gap de framework** (§8) |
| Motor enchufable (HLS/DASH/embed) | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ puerto `MediaProvider` | ✅ **ya por encima** |
| Semántica perceptiva (familia · intent · sonido/háptica) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ pack sema | ✅ **exclusivo** |
| Contrato declarativo verificable (morfo + guards) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ **exclusivo** |
**Lectura.** El transporte ya está a la altura (y el puerto de motor, por
encima). Lo que falta es **todo lo que distingue audio de vídeo**: layout
propio, formas, identidad, velocidad, onda. Dos capacidades que ninguna
referencia tiene ya son nuestras (sema + morfo), y una que **ninguna** de las
ocho implementa —MediaSession— es la palanca barata de superación.
**Fuentes**: [Vidstack components](https://vidstack.io/player/components/) ·
[Vidstack Default Layout](https://vidstack.io/docs/player/components/layouts/default-layout/) ·
[Media Chrome](https://github.com/muxinc/media-chrome) ·
[Plyr](https://github.com/sampotts/plyr) ·
[Red Hat audio player](https://ux.redhat.com/elements/audio-player/) ·
[wavesurfer.js](https://github.com/katspaugh/wavesurfer.js/) ·
[react-h5-audio-player](https://github.com/lhz516/react-h5-audio-player) ·
[MDN MediaSession](https://developer.mozilla.org/en-US/docs/Web/API/MediaSession) ·
[web.dev Media Session](https://web.dev/articles/media-session) ·
[W3C WAI media players](https://www.w3.org/WAI/media/av/player/).
---
## 3. La tensión arquitectónica (leerla antes de las decisiones)
Dos reglas del propio proyecto **apuntan en direcciones opuestas**:
- **P-4 (norma de pickers, `architecture/eidos.md`)**: *«There are no
per-variant components (`MonthPicker`, `HourPicker`). Those forms are
`<X kind='month'>`»*. → un reproductor de audio es `<MediaPlayer media="audio">`.
- **Regla de admisión (packs/blocks)**: *lo que tiene superficie de contrato
propia se promueve a componente canónico y se compone.* → la onda, el menú de
velocidad y la identidad podrían querer casa propia.
La resolución no la dan las reglas sino **las dos referencias primarias, que
coinciden**: Vidstack mantiene UN `<media-player>` y cambia el **layout**
(`DefaultAudioLayout`); Media Chrome mantiene UN `<media-controller>` y cambia
la **composición** de la barra (atributo `audio`). Ninguna bifurca el motor.
Y el catálogo ya tiene el precedente exacto para «una segunda raíz visual sobre
el mismo contrato headless»: **Toast / Toaster** — *«two independent roots (not
nested)… `Toaster` is NOT attached as `Toast.Toaster` because it is a competing
root, not a child»* (`architecture/eidos.md` §Special case). `DefaultControls`
ya es, hoy, una composición eidos-only del mismo tipo.
---
## 4. Decisiones (gate del usuario — SIN FIRMAR)
| ID | Cuestión | Opciones | Recomendación |
| --- | --- | --- | --- |
| **D-AP.1** | **Pertenencia**: ¿componente nuevo o modo del existente? | (a) **un motor, un contrato**: `media-player` conserva morfo/soma/sema y gana partes audio-only `optional: true` + un layout eidos · (b) `audio-player` nuevo que *compone* `media-player` (precedente picker/A27) · (c) fork con su propio provider | **(a)**. Duplicar transporte + puerto + teclado + eventos es exactamente la reinvención que el §4 del component-guide prohíbe; las dos referencias primarias hacen (a); el morfo YA declara `data-media: ['video','audio']` y **ya** marca `optional: true` todas las partes no-núcleo (`PipButton`/`FullscreenButton` son video-only con ese mismo mecanismo — no hace falta inventar nada). (b) añade una capa de indirección sin contrato nuevo. |
| **D-AP.2** | **Puerta pública**: ¿cómo se pide un reproductor de sonido? | (a) `<MediaPlayer media="audio">` + `<MediaPlayer.AudioLayout/>` (espejo de `DefaultControls`) · (b) **raíz competidora** `<AudioPlayer>` en eidos sobre el mismo soma (precedente Toast/Toaster) · (c) ambas | **(c)**: `AudioLayout` como parte eidos-only (composición explícita, coherente con `DefaultControls`) **y** `<AudioPlayer>` como raíz competidora exportada aparte —no como `MediaPlayer.AudioPlayer`— que la monta con `media="audio"`. El 90 % del uso es una etiqueta; el 10 % compone partes. Coste morfo/soma/sema: **cero**. |
| **D-AP.3** | **Formas y variaciones** (petición explícita del usuario) | (a) solo `size` canónico · (b) `variant` propio de componente · (c) `variant` + `size` + responsive | **(c)**. `variant: 'card' \| 'row' \| 'bar' \| 'inline'` como **variante específica de componente** —vía legítima y con precedente escrito (`Banner: inline\|overlay\|persistent`, `Spinner: bars\|dots\|ring`, theming §19)— declarada en su `types.ts`, NO en `EIDOS_VARIANTS`. `size` = subconjunto canónico `xs..xl` (la recipe ya lo mapea). Ambas admiten `ResponsiveProp<T>`. Detalle en §6. |
| **D-AP.4** | **Waveform** | (a) componente canónico `Waveform` propio (ruta de 9 fases) · (b) parte eidos-only del player · (c) fuera | **(a), pero en su propia iniciativa, DESPUÉS del v1**. Tiene superficie de contrato real (es un `role="slider"` con teclado, `data-*` y eventos `handle-*`), y **sirve fuera del player** (nota de voz en `chat-message`, editor). Meterlo como parte eidos-only sería crecerle el privilegio al player — el antipatrón que la regla de admisión existe para impedir. Los picos: **`peaks: number[]` que aporta la app** (ruta profesional: BBC `audiowaveform`/SoundCloud) con decodificador cliente **opcional**; el renderer reutiliza `$libs/plots` (`scaleLinear` + `area`/`line`), no inventa matemáticas. |
| **D-AP.5** | **MediaSession (SO)** | (a) prop opt-in en el provider soma · (b) art nuevo `$media-session` · (c) app-land | **(a) opt-in, apagado por defecto**, `mediaSession={{ title, artist, album, artwork }}`. Es *exactamente* el snapshot que el provider ya posee (`playbackState`, `setPositionState`, `seekto`), y dejarlo fuera obliga a cada consumidor a re-derivarlo. **Riesgo declarado**: `navigator.mediaSession` es **singleton de documento** — dos players activos se pisan; el provider debe registrar solo mientras `!paused` y liberar en `pause`/`dispose`. (b) es sobreingeniería para una API sin estado propio. Ninguna de las 8 referencias lo trae → superación barata. |
| **D-AP.6** | **Cola / playlist** | (a) en el player · (b) block/app-land | **(b), fuera de alcance**. Una cola es una composición de `Listbox` + estado de aplicación: cero contrato nuevo → tier `blocks` o app. El player expone `commit-complete` y ya. Se registra en `next-features.md`. |
| **D-AP.7** | **Firma sema en modo audio** | (a) heredar el pack actual · (b) **silenciar el canal sonoro en audio** · (c) pack propio completo | **(b)**. Doctrina D.7 (`book-deviations`): *«En eventos frecuentes, la prioridad es evitar fatiga. El silencio es una firma válida»* + antipatrón cap. 34 §10 «sonido decorativo». Si el contenido ES audio, un *tick* de UI compite con la obra. Regla nueva en el pack: selector `[data-media-player][data-media='audio']` → `channels: ['haptic']`. Es una regla que **añade carácter sin tocar las primitivas del intent** (regla dura de la cascada). |
| **D-AP.8** | **Capítulos + transcripción** | (a) v1 · (b) v2 · (c) fuera | **(b) v2**, registrado con disposición en el README (F-1.4). Capítulos = pista secundaria del scrubber → **depende de D-AP.4/H-3**; transcripción = superficie de contenido con su propia a11y (candidata a componente propio, patrón Red Hat). |
| **D-AP.9** | **Responsive del layout** | (a) `ResponsiveProp` (mecanismo del framework) · (b) container queries del TSC · (c) ambos | **(a) en v1, (b) evaluado en F6**. El TSC ya tiene el eje `container: {}` documentado como **«a themeable axis with 0 consumers today — an open cage»** (`canon/tsc.md`): un player que vive dentro de un aside, una tarjeta o un pie de página es el **primer consumidor natural** del catálogo, y resolvería mejor que el `smallLayoutWhen` de Vidstack (que depende del contenedor, no del viewport). Abrir la jaula es un entregable con valor propio — pero no debe bloquear el v1. |
| **D-AP.10** | **Alcance del v1** | (a) paridad estricta con las referencias · (b) paridad + superación (MediaSession + silencio sema + container) | **(a) + MediaSession + silencio sema**; container y waveform como iniciativas hermanas. Criterio: el usuario pidió *«como mínimo al mismo nivel»* — la paridad es el suelo, no el techo, y las dos superaciones baratas caben. |
---
## 5. Arquitectura propuesta (tras firma de D-AP.1/2)
```
morfo/components/media-player.ts +6 partes optional (audio-only) · +2 eventos · +1 texto
│ (contrato ÚNICO — no se bifurca)
soma/components/media-player/ +sub-providers de las partes nuevas
│ +MediaSession opt-in en el provider raíz
│ +formatTime → uix.format (H-5)
sema/components/media-player.ts +regla de silencio en audio (D-AP.7) · fix H-1
│
eidos/components/media-player/ +audio-layout.svelte (composición, espejo de DefaultControls)
│ +recipe: variantes card/row/bar/inline
eidos/components/audio-player/ raíz competidora <AudioPlayer> (Toast/Toaster)
```
**Lo que NO se toca**: el puerto `MediaProvider` (ya es el acierto del
componente), el modo vídeo, el recipe Lumière existente, la demo de
`media-player`.
**Dogfooding obligatorio** (§4 component-guide): la barra compone `IconButton`
(ya lo hace), los sliders componen `Slider` (ya), el menú de velocidad compone
`DropdownMenu`, la carátula compone `Image` + `AspectRatio`, la identidad
compone `Text`/`Heading`, el estado vacío compone `EmptyState`. **Cero
primitivas inline.**
---
## 6. Formas y variaciones (el punto 3 del encargo, en detalle)
Cuatro formas, un solo contrato. La variante decide **qué partes monta el
layout y cómo se disponen**, nunca qué puede hacer el player.
| `variant` | Forma | Partes que monta | Contexto de uso | Referencia del sector |
| --- | --- | --- | --- | --- |
| **`card`** | Tarjeta vertical: carátula grande arriba, identidad, scrubber, transporte centrado | Artwork · Title · Artist · TimeSlider · Time×2 · Play · Seek± · Mute · Volume · Rate | Página de episodio, ficha de álbum, landing | Apple Podcasts / Bandcamp |
| **`row`** | Fila horizontal: carátula pequeña a la izquierda, identidad + scrubber apilados | Artwork(sm) · Title · Artist · TimeSlider · Play · Seek± · Rate | Lista de episodios, resultado de búsqueda, feed | SoundCloud track |
| **`bar`** | Barra persistente a ancho completo (composición pensada para `sticky`/pie) | Artwork(xs) · Title · Play · Seek± · TimeSlider · Time · Mute · Volume · Rate | Reproductor global de la app | Spotify bottom bar |
| **`inline`** | Mínima, en línea con el texto: play + scrubber + tiempo | Play · TimeSlider · Time | Nota de voz en un chat, cita de audio en un artículo | mensaje de voz de WhatsApp |
Ejes ortogonales que **componen** con la variante:
- **`size`** (`xs..xl`, `ResponsiveProp`) — densidad; la recipe ya mapea el
bundle `--size-{k}-*`. Regla dura: `md` no cambia de significado por viewport
(theming §5); lo que cambia es **qué size se elige**.
- **`color`** — el sistema completo (rol / intent / 33 escalas / CSS crudo =
`ComponentColorProp`), sin subconjuntos: decisión «abrir la jaula del color»
(theming §25, guard en `recipe-css-contract.test.ts`).
- **`variant` responsive** — `variant={{ base: 'inline', md: 'row', lg: 'card' }}`.
- **`data-depth`** — la barra persistente estampa su plano (`raised`/`overlay`),
no inventa sombras (contrato de recipe R-4.1).
**Regla de forma** (espejo de la que rige `blocks`): la variante **no puede
añadir comportamiento**. Si una forma necesitase un evento, un `data-*` que el
CSS deba seleccionar o una obligación a11y nueva, eso se promueve al contrato
—no se le crece el privilegio al layout.
---
## 7. Contrato propuesto (borrador para F1 — se cierra con la firma)
### 7.1 Partes nuevas (todas `optional: true`, audio-only)
| Parte | kebab | archetype | Elemento | Rol |
| --- | --- | --- | --- | --- |
| `Artwork` | `artwork` | `image` | `img` | Carátula. Compone `Image`+`AspectRatio`; `alt` real (no decorativa como `Poster`, porque en audio **es** la única representación visual de la obra). |
| `Artist` | `artist` | `description` | `div` | Autoría / serie. |
| `RateButton` | `rate-button` | `trigger` | `button` | Abre el menú de velocidad (compone `DropdownMenu`) o cicla el preset. Cierra H-2 junto a `SettingsButton`. |
| `Identity` | `identity` | `group` | `div` | Agrupador de Artwork+Title+Artist (le da a la variante un bloque que mover). |
| `Transport` | `transport` | `group` | `div` | Agrupador de los botones de transporte, hermano de `Controls`. |
| `LiveIndicator` | `live-indicator` | `indicator` | `div` | Emisión en directo (`data-live` ya existe en el provider). |
### 7.2 Eventos nuevos
| Evento | Familia · verbo | Intent | Target | `sequence` | Por qué |
| --- | --- | --- | --- | --- | --- |
| `commit-set-rate` | `commit` · `set` | `neutral` | `rate-button` | `post` | Fijar velocidad **es** un valor aplicado (`commit.set`, BOOK_CANON A.5: *«un valor, criterio o parámetro ha quedado aplicado»* — mismo caso que sort/slider). |
| `sustain-end` | `sustain` · `end` | — | `provider` | `coincident` | Cierre del proceso de buffering. **Solo si** F0 confirma que `sustain-loading` (`persistence: 'stateBound'`) no se limpia solo; si se limpia, **no se declara** (regla B.5: un cambio no perceptible no es evento). |
Nada más. `loop` es prop nativa sin evento (B.5). El cambio de carátula/título es
dato, no ocurrencia.
### 7.3 Teclado
Se hereda el contrato del provider (9 teclas). Se añaden, alineadas con el
sector (`j`/`l` de YouTube, `<`/`>` de velocidad):
| Tecla | Acción | Nota |
| --- | --- | --- |
| `j` / `l` | seek ∓ `seekStep` | alias de ←/→, convención del sector |
| `<` / `>` | bajar / subir velocidad | requiere `RateButton` montado |
| `0`–`9` | saltar al 0–90 % | **solo cuando `duration` es finita** (no en directo) |
### 7.4 A11y — el listón
- **`aria-valuetext` hablado en el scrubber** (cierra H-4): *«2 minutos 7
segundos de 41 minutos»*, no «127». Es **gap de framework** (§8).
- `Artwork` con `alt` real; el resto de la identidad como texto real (nunca
`aria-label` sobre `div`s vacíos).
- `commit-fail` ya declara `requiresLiveRegion` + `reducedMotionFallback: 'text'`
— se conserva.
- Contraste: la barra `bar` sobre superficie arbitraria consume `data-depth`, no
color inventado (R-2.1/R-4.6).
- Sin robo de foco al cambiar de pista.
---
## 8. Gaps de framework a promover ANTES (regla de admisión)
Se construyen **primero**, en el canon, y el player los compone. Ninguno es un
parche local del player.
| # | Gap | Dónde | Justificación 2-de-3 | Coste |
| --- | --- | --- | --- | --- |
| **G-1** | `aria-valuetext` en el morfo de `Slider` (`v.propRef('valueText')`) + prop `valueText` en soma | `morfo/components/slider.ts` + provider | `aria` es campo morfo por definición (soma emite, eidos puede seleccionar) | bajo |
| **G-2** | Pista secundaria («buffered»/«secondary range») en `Slider` | `morfo` + `soma` + recipe | Lo consumen soma (valor) y eidos (pintura) → 2-de-3 ✓. Hoy lo simula un `div` eidos-only en media-player y lo simularía otro en `Waveform` — dos copias = la señal de que falta la pieza | medio |
| **G-3** | `formatDuration` en `$libs/days` (o `uix.format`) | `$libs/days` | Regla A23: si es puro y reutilizable, va a la lib — nunca reimplementar dentro de soma | bajo |
| **G-4** | Verificar/arreglar H-1 (selector del pack sema) | `sema/components/media-player.ts` | fix de una línea si se confirma | trivial |
`Waveform` (D-AP.4) **no** está en esta tabla: no es un gap del player sino una
iniciativa hermana con su propia ruta de 9 fases.
---
## 9. Fases (tras el gate)
| Fase | Contenido | Verify |
| --- | --- | --- |
| **F0 — Verificación y firma** | Confirmar H-1 (stamp real en navegador) · confirmar si `sustain-loading` se auto-limpia (decide 7.2) · leer `DefaultControls`, `Slider` provider y la recipe del scrubber ANTES de diseñar (regla `feedback_study_real_recipes_before_building`) · presentar §4 y §6 al usuario y **firmar** | tabla de decisiones firmada en el chat; este plan actualizado |
| **F1 — Gaps de framework** | G-1 · G-2 · G-3 · G-4, cada uno con su test | `check` 0 propios · vitest del slider verde · `component:audit --only slider` PASS · `morfo:vocabulary` exit 0 |
| **F2 — Morfo** | 6 partes + 1–2 eventos + `texts` + catálogo `langs/components/media-player.ts` | `validateMorfo` · test del morfo · `morfo:vocabulary` · `translations:check` |
| **F3 — Soma** | sub-providers de las partes nuevas · MediaSession opt-in (con guarda de singleton) · `formatTime`→G-3 · tests del provider | test del provider verde · `check` 0 propios |
| **F4 — Sema** | regla de silencio en audio (D-AP.7) + fix H-1; `expression` sigue `'pack'` | `morfo:vocabulary` (coherencia pack↔expression, guard S11d) · stamp verificado en navegador |
| **F5 — Eidos** | `audio-layout.svelte` + raíz `<AudioPlayer>` + recipe de las 4 variantes + tokens `--audio-player-*` en `lib/recipes/base.ts` | `generate:eidos-css` (TSC) · `recipe-css-contract` · `eidos-lint` 0 inválidos · `component-api-contract` |
| **F6 — Formas** | responsive de `variant`/`size`; evaluar container queries (D-AP.9) | prueba visual de las 4 variantes × light/dark × RTL × densidad, **con captura y mirada** (regla dura `feedback_verify_visually_before_showing`) |
| **F7 — Demo + dossier + cierre** | demo v2 de 9 pestañas (cada prop = control vivo, paridad de chips D-7.4) · README eidos (Baseline/Comparativa≥3/Decisiones/Gaps con disposición) · README soma actualizado · `data-perm-step` | `component:audit --only media-player` **PASS 0 errores** · `smoke` · `perm:check` · `check` 0 propios · `docs:check` |
**Método**: no-cascada — una pieza, verificar, la siguiente (método canonizado
en [`_cierre.md`](../audit/components/_cierre.md)). Gates completos al cerrar
cada fase.
---
## 10. Fuera de alcance
- **Cola / playlist** (D-AP.6) → `blocks` o app; se registra en `next-features.md`.
- **Waveform** (D-AP.4) → iniciativa hermana, ruta de 9 fases propia.
- **Capítulos, transcripción, ganancia >100 %, ecualizador, grabación** → v2,
cada uno con disposición escrita en el `## Gaps` del README.
- **Vendorizar motores** (hls.js, Howler, wavesurfer) → el puerto `MediaProvider`
ya es la respuesta; doctrina de cero dependencias.
- **Visualizador/espectro** → si algún día se hace, su hogar es `$scene`
(`EngineScene` ya resuelve frame-loop, pausa fuera de vista, cap de DPR,
reduced-motion obligatoria, pérdida de contexto y presupuesto) o el tier
`packs` — **nunca** un canvas suelto dentro del recipe del player.
---
## 11. Riesgos declarados
| Riesgo | Mitigación |
| --- | --- |
| El morfo de `media-player` ya es el mayor del catálogo; crece más | Todas las partes nuevas `optional: true`; si tras F2 el fichero pasa de ~700 líneas, reabrir D-AP.1(b) con datos, no con intuición |
| MediaSession es singleton de documento | Registro solo mientras suena + liberación en `pause`/`dispose`; documentado en el README como limitación conocida |
| La variante `bar` vive sobre fondos arbitrarios | `data-depth` + el layer de estado; nunca color crudo |
| Sobrecarga de alcance | El v1 es la columna «implementar» de §2 y nada más; todo lo demás lleva disposición escrita |

@ -0,0 +1,295 @@
# 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**: **COMPLETO — D-SND.1…D-SND.6 firmadas · F0 ✅ F1 ✅ F2 ✅ F3 ✅ F4 ✅**.
> Queda solo **F5** (desbloquear [`PLAN-audio-player.md`](./PLAN-audio-player.md)),
> que es una decisión de sesión, no trabajo pendiente de esta iniciativa.
> 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.
> **17 suites / 198 tests verdes · `check` en la baseline exacta (73, 0 propios).**
> **Kickoff para sesión nueva**: *"Lee `docs/process/PLAN-sound-engine.md` y continúa la fase que toque."*
>
> **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 |
**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**.
## 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). |
| **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** | Marcar [`PLAN-audio-player.md`](./PLAN-audio-player.md) como desbloqueado y reescribir su D-AP.11 contra el art ya existente | — |
**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ó.

@ -117,6 +117,7 @@ errors are typed.
| [`adom`](./adom/README.md) | `ActiveDom` | Reactive DOM service: viewport, breakpoints, attribute writes, scroll lock, post-layout read scheduling (`measure`) | `$libs/dom`, `$reactive` |
| [`motion`](./motion/README.md) | `EngineMotion` | Animation runtime: registers + runs `--state` presets (CSS settle / JS drivers — spring / waapi / rect FLIP); the bridge BOTH UIX layers consume via `uix.motion` | `MotionDom` port (injected; `adom` satisfies it) |
| [`scene`](./scene/README.md) | `EngineScene` | Ambient-scene runtime: mounts WebGL/canvas-2D effects with the citizenship done once (frame loop, off-view pause, DPR cap, mandatory reduced-motion policy, context loss/restore, scene budget, teardown); effects = shared resources for the `Ambient` pack + the canonical `Aura` (promoted to `uix.scene` via `defineEngineScene`, D4) | `SceneDom` port (injected; `adom` satisfies it) |
| [`sound`](./sound/README.md) | `EngineSound` | Web Audio runtime: ONE `AudioContext` per document (browsers cap them and the autoplay unlock gesture is per-context), earcon synthesis (oscillator pair → biquad → ADSR → AM), sample playback with cache + synth fallback, unlock-on-gesture, opt-in `autoSuspend`, second-live-context warning. Extracted from sema's `SoundChannel` (2026-07-30) — sema keeps the doctrine, the art keeps the machine; `sound.context` is public so a player / waveform / visualiser hangs its own graph off the same context | `SoundDom` + `SoundTimers` ports (injected; `adom` / `timer` satisfy them) |
| [`ethereal`](./ethereal/README.md) | `$ethereal` (`computePosition` + middleware) | In-house positioning engine: collision-aware placement (offset/shift/flip/arrow/size/hide), `autoUpdate`, native CSS-anchor strategy — our parity-verified subset of `@floating-ui`; the JS + CSS paths BOTH UIX layers consume | `$adom` (DOM reads); no other art (`@floating-ui` = devDep parity baseline) |
| [`clipboard`](./clipboard/README.md) | `ActiveClipboard` | Clipboard write capability with injectable writer and explicit unavailable errors | browser `navigator.clipboard` or injected writer |
| [`sium`](./sium/README.md) | `EngineSium` | Validation contracts: schemas, issues, introspection, Standard Schema interop | `$langs` (optional), `$logger` (optional), `$libs/days`, `$libs/color` |
@ -224,6 +225,13 @@ adom ──────────────────\ | / | connect
`$cache` owns the active Svelte wrapper and can consume `storage` through its
storage adapter.
- `adom` depends only on the pure helpers in `libs/dom` and on `libs/reactive`.
- `sound` is the Web Audio engine, consumed by sema's `SoundChannel` (through an
injected port — sema imports no art) and by anything that needs its own audio
graph, via `uix.sound.context`. Same shape as `motion` / `ethereal`: the art
owns the RUNTIME, the layer keeps its DATA and doctrine (`SOUND_LIBRARY`,
`SOUND_TUNINGS`, the cascade). It imports no other art — `SoundDom` and
`SoundTimers` arrive injected. **One context per document is its whole reason
to exist**, so it warns when a second one goes live.
- `motion` is the animation engine consumed by BOTH UIX layers via `uix.motion`
(soma's `Presence` + eidos wrappers), which dissolves the would-be soma→eidos
coupling. It imports no other art — the DOM dependency arrives injected via the
@ -269,6 +277,7 @@ alias: {
$prefs: 'src/arts/prefs',
$session: 'src/arts/session',
$sium: 'src/arts/sium',
$sound: 'src/arts/sound',
$storage: 'src/arts/storage',
$svrs: 'src/svrs',
$timer: 'src/arts/timer',

@ -34,3 +34,4 @@ export { defineEngineHttp } from './http.ts';
export { defineEngineMotion } from './motion.ts';
export { defineEngineScene } from './scene.ts';
export { defineEngineSium } from './sium.ts';
export { defineEngineSound } from './sound.ts';

@ -0,0 +1,42 @@
import { createEngineSound } from '$sound';
import type { EngineSound, SoundDom, SoundTimers } from '$sound';
import type { AppServiceFactory } from '../services.ts';
/**
* `defineEngineSound()` produces the service factory for the `sound` slot — the
* shared Web Audio runtime extracted from sema's `SoundChannel` (2026-07-30).
*
* It is a SINGLE engine on purpose: the browser caps concurrent
* `AudioContext`s and the autoplay unlock gesture is per-context, so a second
* one would leave either the UI earcons or the app's own content mute. Every
* consumer — sema's sound channel, a media player, a waveform, a visualiser —
* meets here and hangs its graph off `sound.context`.
*
* Needs the DOM service (the global unlock listener) via
* `serviceDependencies: ['dom']`, and the core `timers` for the earcon's
* completion. Both degrade: without `dom` a suspended context is resumed on the
* next `prime()` instead of on the next gesture; without `timers` the engine
* falls back to `setTimeout`.
*/
export function defineEngineSound(): AppServiceFactory<
'sound',
readonly ['timers'],
readonly ['dom'],
EngineSound
> {
return {
name: 'sound',
coreDependencies: ['timers'],
serviceDependencies: ['dom'],
initMode: 'lazy',
create({ core, services }): EngineSound {
return createEngineSound({
dom: services.dom as SoundDom | undefined,
timers: core.timers as SoundTimers | undefined
});
},
dispose(instance) {
instance.dispose();
}
};
}

@ -0,0 +1,120 @@
# sound — the Web Audio runtime
`EngineSound` owns the audio machinery: **one** `AudioContext`, its unlock
lifecycle, the earcon synthesis graph and the sample path. It is an art (a pure
runtime artifact — public methods over private state, no `$state`) so that every
consumer meets at **one context** instead of opening a second one: the browser
caps concurrent contexts and the autoplay unlock gesture is *per context*.
```ts
const sound = createEngineSound({ dom, timers }); // dom: a SoundDom port
sound.prime(); // SYNCHRONOUS — call it inside the user gesture
await sound.play(signature); // an earcon; never rejects
await sound.preload(urls); // pre-decode samples
sound.setMasterGain(0.4); // the caller's policy, applied by the engine
sound.suspend() / sound.resume(); // explicit; wins over the autoSuspend policy
sound.dispose(); // idempotent
sound.state; // 'absent' | 'suspended' | 'running'
sound.context; // AudioContext | null — the raw output
```
## The split: machinery here, doctrine in sema
This art was extracted from `uix/sema/chans/sound.ts` (2026-07-30), where ~63%
of a 407-line "channel" was Web Audio machinery. The line is sharp and has an
objective test:
> **This art imports nothing from `$uix/sema`.** If it ever needs to, the cut was
> drawn wrong.
| Here (machinery) | sema (doctrine) |
| --- | --- |
| `AudioContext` lifecycle · unlock on gesture · master gain · sample cache · synthesis graph · contour | `SOUND_LIBRARY` · `SOUND_TUNINGS` · the gesture resolvers · the cascade · which signature for which family × intent · the reduction policy |
A `SoundSignature` here is nine numeric knobs plus a contour — no family, no
intent, no evaluative loading. Sema's identically shaped type is structurally
assignable to it.
## `sound.context` — why the raw output is public
A consumer that needs its own graph (an `AnalyserNode` for a visualiser, gain
above 1, a `BufferSource`, sample-accurate scheduling) hangs it off **this**
context. That is the whole reason the art exists: one owner, many consumers.
## The ports
The art imports no other art. Both dependencies arrive injected as structural
ports — the `MotionDom` / `SceneDom` pattern:
- **`SoundDom`** (`getDocument` + `listen`) — only to register the global unlock
listener. `ActiveDom` satisfies it. Without it the engine still plays; a
context suspended by the autoplay policy is then resumed on the next
`prime()` / `play()` instead of on the next gesture.
- **`SoundTimers`** (the `schedule` slice of `TimerScheduler`) — the earcon's
completion timer. `ActiveTimers` / `uix.timers` satisfies it; without it the
engine falls back to `setTimeout` (direct unit tests only).
## `autoSuspend` is opt-in — and that is the whole point
`autoSuspend: true` suspends the context while the tab is hidden and resumes it
when it returns. It defaults to **`false`**, deliberately:
> Suspending on a hidden tab is the right policy for UI earcons and the **wrong**
> one for content. People listen to podcasts with the tab hidden, and a media
> player hangs its graph off this same context. Which of the two you are is a
> decision of whoever composes — never of the engine.
`suspend()` / `resume()` called explicitly always win: the policy only
auto-resumes what it auto-suspended.
## One live context, and it says so
Constructing a second engine is **harmless** — an engine with no context costs
nothing, which is exactly why `ActiveUix` can create one unconditionally. What is
almost always a wiring mistake is a second **live context**: browsers cap
concurrent `AudioContext`s and the unlock gesture is per-context, so one of the
two ends up mute.
So the art counts live contexts per realm and warns through the injected logger
when a second one appears, pointing at the fix (take `uix.sound`, or pass
`soundEngine` to `EngineSemantic`). It is a warn, not an error: two contexts are
degraded, not broken.
## No side effects until it sounds
The constructor creates no context and registers no listener. An app that never
plays a sound pays for nothing — and the sound path only enters the bundle when
something imports this art.
## Behaviour notes (preserved from the original channel)
- **Autoplay policy.** The context starts suspended in Safari / iOS. `prime()`
creates + resumes it *synchronously* from inside the gesture; the unlock
listener is registered afterwards, on the owner document, capture phase.
- **Synthesis.** Two oscillators (sine + a fifth) → biquad lowpass (`centroid`)
→ ADSR-lite. Attack is clamped to ≤ 15% of the note and release to ≤ 40%, so
short notes keep an audible envelope instead of collapsing to a square.
`roughness > 0.2` adds a fast AM modulator. Contour rides `osc.detune` at
±400 cents — ±50 was imperceptible at typical hold durations.
- **Samples.** A signature carrying `sampleUrl` plays the decoded buffer (cached)
and **falls back to synthesis** when fetch / decode fails, so the perceptual
signal is never silently lost.
- **Errors are absorbed.** Audio is ornamental: a failed earcon must never abort
the caller's operation. Failures go to the injected `logger` at `debug`.
## Composition
```ts
// active-app (attach path)
const App = createActiveApp({
services: { dom: defineActiveDom(), sound: defineEngineSound() }
});
// active-uix (standalone) creates it directly and exposes uix.sound.
```
Ownership rule for consumers: **whoever creates the engine disposes it.** A
consumer that receives a shared engine (injected by the composition root) must
never call `dispose()` on it.

@ -0,0 +1,42 @@
/**
* Named constants for the sound art.
*
* Per the arts contract, message strings are constants — never inline literals
* in runtime logic. This art ships NO `diagnostics.ts` catalog and NO
* `errors.ts`, and that is a decision, not an omission: mirroring `$scene`, the
* closest sibling, every failure path here is a documented degradation (no
* `AudioContext`, a decode failure, a rejected `resume()`) rather than a
* programmer error. Audio is ornamental — it must never abort its caller. What
* the art owes is an honest log line through the injected logger.
*/
/** Logger category for everything this art emits. */
export const SOUND_LOGGER_CATEGORY = 'sound.engine';
export const SOUND_LOGS = {
/** The audio graph threw while playing an earcon. Absorbed. */
PLAY_FAILED: 'play failed',
/**
* A second `AudioContext` went live in this realm. Constructing a second
* ENGINE is harmless (an engine with no context costs nothing, and
* `ActiveUix` creates one unconditionally by design) — what is almost always
* a wiring mistake is a second live CONTEXT: browsers cap them and the
* autoplay unlock gesture is per-context, so one of the two ends up mute.
* The fix is to take the shared engine (`uix.sound`, or `soundEngine` on
* `EngineSemantic`) instead of letting a consumer open its own.
*/
MULTIPLE_CONTEXTS:
'a second AudioContext went live in this document — consume the shared engine (uix.sound / EngineSemantic soundEngine) instead of opening your own: browsers cap concurrent contexts and the autoplay unlock gesture is per-context, so one of them will end up mute'
} as const;
/** The gesture set that unlocks a context suspended by the autoplay policy. */
export const SOUND_UNLOCK_EVENTS = [
'pointerdown',
'mousedown',
'click',
'touchstart',
'keydown'
] as const;
/** Event the opt-in `autoSuspend` policy listens to. */
export const SOUND_VISIBILITY_EVENT = 'visibilitychange';

@ -0,0 +1,260 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest';
import { createEngineSound } from './engine-sound';
import type { SoundDom, SoundSignature } from './types';
/**
* Engine-level mirror of the regression contract the sema `SoundChannel` test
* pins (RC-1…RC-3, RC-5). The channel keeps testing the POLICY (reduction
* levels, the prepare gate); this suite tests the MACHINERY that moved here.
*/
function createDomHarness() {
const cleanup = vi.fn();
return {
cleanup,
dom: {
getDocument: vi.fn(() => document),
listen: vi.fn(() => cleanup)
} satisfies SoundDom
};
}
function createAudioHarness() {
const resume = vi.fn(() => Promise.resolve());
const close = vi.fn(() => Promise.resolve());
const gainNode = {
gain: { value: 0 },
connect: vi.fn()
};
const ctx = {
state: 'suspended',
destination: {},
createGain: vi.fn(() => gainNode),
resume,
close
} as unknown as AudioContext;
return { ctx, factory: vi.fn(() => ctx), gainNode, resume, close };
}
const signature: SoundSignature = {
pitch: 700,
centroid: 1800,
roughness: 0.1,
attack: 8,
decay: 120,
duration: 100,
contour: 'flat',
gain: 0.3
};
describe('EngineSound', () => {
it('has no side effects in the factory (RC-1)', () => {
const { dom } = createDomHarness();
const audio = createAudioHarness();
const sound = createEngineSound({ dom, audioContextFactory: audio.factory });
expect(audio.factory).not.toHaveBeenCalled();
expect(dom.listen).not.toHaveBeenCalled();
expect(sound.state).toBe('absent');
expect(sound.context).toBeNull();
sound.dispose();
});
it('primes synchronously and registers the unlock listener once (RC-2)', () => {
const { cleanup, dom } = createDomHarness();
const audio = createAudioHarness();
const sound = createEngineSound({
dom,
audioContextFactory: audio.factory,
masterGain: 0.4
});
sound.prime();
expect(audio.factory).toHaveBeenCalledTimes(1);
expect(audio.ctx.createGain).toHaveBeenCalledTimes(1);
expect(audio.gainNode.gain.value).toBe(0.4);
expect(audio.resume).toHaveBeenCalledTimes(1);
expect(dom.getDocument).toHaveBeenCalledTimes(1);
expect(dom.listen).toHaveBeenCalledWith(
document,
['pointerdown', 'mousedown', 'click', 'touchstart', 'keydown'],
expect.any(Function),
true
);
// Priming twice must not open a second context nor a second listener.
sound.prime();
expect(audio.factory).toHaveBeenCalledTimes(1);
expect(dom.listen).toHaveBeenCalledTimes(1);
sound.dispose();
expect(cleanup).toHaveBeenCalledTimes(1);
expect(audio.close).toHaveBeenCalledTimes(1);
});
it('dispose is idempotent and leaves the engine inert (RC-3)', () => {
const { cleanup, dom } = createDomHarness();
const audio = createAudioHarness();
const sound = createEngineSound({ dom, audioContextFactory: audio.factory });
sound.prime();
sound.dispose();
sound.dispose();
expect(cleanup).toHaveBeenCalledTimes(1);
expect(audio.close).toHaveBeenCalledTimes(1);
expect(sound.state).toBe('absent');
// A disposed engine does not resurrect the context.
sound.prime();
expect(audio.factory).toHaveBeenCalledTimes(1);
});
it('applies masterGain × gainScale BEFORE building the graph (RC-5)', async () => {
const { dom } = createDomHarness();
const audio = createAudioHarness();
(audio.ctx as { state: string }).state = 'running';
const sound = createEngineSound({
dom,
audioContextFactory: audio.factory,
masterGain: 1
});
// The bare fake context has no `createOscillator`: the synth path throws
// and is absorbed — but the gain must already be committed.
await sound.play(signature, { gainScale: 0.4 });
expect(audio.gainNode.gain.value).toBeCloseTo(0.4);
sound.dispose();
});
it('never rejects when the audio graph fails', async () => {
const audio = createAudioHarness();
(audio.ctx as { state: string }).state = 'running';
const sound = createEngineSound({ audioContextFactory: audio.factory });
await expect(sound.play(signature)).resolves.toBeUndefined();
sound.dispose();
});
it('degrades to a no-op when no AudioContext can be created', async () => {
const sound = createEngineSound({ audioContextFactory: () => null });
sound.prime();
expect(sound.state).toBe('absent');
await expect(sound.play(signature)).resolves.toBeUndefined();
sound.dispose();
});
it('setMasterGain updates the live node', () => {
const audio = createAudioHarness();
const sound = createEngineSound({ audioContextFactory: audio.factory, masterGain: 1 });
sound.prime();
expect(audio.gainNode.gain.value).toBe(1);
sound.setMasterGain(0.25);
expect(audio.gainNode.gain.value).toBe(0.25);
sound.dispose();
});
// ── F3 · citizenship ─────────────────────────────────────────────────────
it('does NOT auto-suspend on a hidden tab by default', () => {
const { dom } = createDomHarness();
const audio = createAudioHarness();
const sound = createEngineSound({ dom, audioContextFactory: audio.factory });
sound.prime();
// Only the unlock listener. Suspending is right for earcons and WRONG for
// content (a podcast keeps playing with the tab hidden), so it is the
// composer's decision, never the engine's default.
expect(dom.listen).toHaveBeenCalledTimes(1);
expect(dom.getDocument).toHaveBeenCalledTimes(1);
sound.dispose();
});
it('suspends and resumes with tab visibility when autoSuspend is on', () => {
const { dom } = createDomHarness();
const audio = createAudioHarness();
const suspend = vi.fn(() => Promise.resolve());
(audio.ctx as unknown as { suspend: unknown }).suspend = suspend;
const handlers: EventListener[] = [];
dom.listen.mockImplementation(((_t: EventTarget, ev: string | readonly string[], h: EventListener) => {
if (ev === 'visibilitychange') handlers.push(h);
return vi.fn();
}) as never);
const sound = createEngineSound({ dom, audioContextFactory: audio.factory, autoSuspend: true });
sound.prime();
// Still ONE getDocument (the contract pins it) even with two listeners.
expect(dom.getDocument).toHaveBeenCalledTimes(1);
expect(handlers).toHaveLength(1);
(audio.ctx as { state: string }).state = 'running';
Object.defineProperty(document, 'hidden', { value: true, configurable: true });
handlers[0](new Event('visibilitychange'));
expect(suspend).toHaveBeenCalledTimes(1);
(audio.ctx as { state: string }).state = 'suspended';
Object.defineProperty(document, 'hidden', { value: false, configurable: true });
audio.resume.mockClear();
handlers[0](new Event('visibilitychange'));
expect(audio.resume).toHaveBeenCalledTimes(1);
sound.dispose();
});
it('warns when a SECOND context goes live, not when a second engine is built', () => {
const logger = { warn: vi.fn(), debug: vi.fn() } as never;
const a = createAudioHarness();
const b = createAudioHarness();
// Two engines, zero contexts: harmless — ActiveUix builds one
// unconditionally by design, so this must stay quiet.
const warn = (logger as unknown as { warn: ReturnType<typeof vi.fn> }).warn;
const first = createEngineSound({ audioContextFactory: a.factory, logger });
const second = createEngineSound({ audioContextFactory: b.factory, logger });
// Constructing NEVER warns — the counter only moves on context creation,
// so this assertion holds whatever the module counter is at.
expect(warn).not.toHaveBeenCalled();
// Deltas, not absolutes: the counter is realm-global by design (that is
// how it detects the anti-pattern), so the test must not depend on what
// earlier tests left behind.
first.prime();
const afterFirst = warn.mock.calls.length;
second.prime();
const afterSecond = warn.mock.calls.length;
expect(afterSecond).toBeGreaterThan(afterFirst);
expect(warn.mock.calls.at(-1)?.[1]).toMatch(/second AudioContext/);
first.dispose();
second.dispose();
});
it('exposes the raw context so consumers share one graph', () => {
const audio = createAudioHarness();
const sound = createEngineSound({ audioContextFactory: audio.factory });
expect(sound.context).toBeNull();
sound.prime();
expect(sound.context).toBe(audio.ctx);
sound.dispose();
});
});

@ -0,0 +1,443 @@
import type {
EngineSound,
EngineSoundOptions,
SoundDom,
SoundEngineState,
SoundPlayOptions,
SoundSignature,
SoundTimers
} from './types';
import type { Logger } from '$libs/logger';
import {
SOUND_LOGGER_CATEGORY,
SOUND_LOGS,
SOUND_UNLOCK_EVENTS,
SOUND_VISIBILITY_EVENT
} from './consts';
/**
* # EngineSound — the Web Audio runtime
*
* Extracted verbatim from `uix/sema/chans/sound.ts` (2026-07-30): the context
* lifecycle, the synthesis graph and the sample path were ~63% of that channel
* and are machinery, not perceptual doctrine. Sema keeps the doctrine (which
* signature for which family × intent, the tunings, the cascade, the reduction
* policy) and consumes this engine through an injected port.
*
* Behaviour, preserved exactly:
* - ONE `AudioContext` with a master `GainNode`.
* - Browsers with an autoplay policy start it suspended; `prime()` creates +
* resumes it SYNCHRONOUSLY from inside the user gesture. The global unlock
* listener is registered AFTER the context exists, never in the
* constructor — an engine that never plays installs no listeners.
* - Synthesis: two oscillators (sine + a fifth) → biquad lowpass (centroid)
* → ADSR-lite envelope. `roughness > 0.2` adds a fast AM modulator.
* Contour is applied via `osc.detune` (±400 cents).
* - A signature carrying `sampleUrl` plays the sample (with an `AudioBuffer`
* cache) and falls back to synthesis when fetch / decode fails, so the
* perceptual signal is never silently lost.
*
* Errors are absorbed: audio is ornamental, and a failed earcon must never
* abort the caller's operation.
*/
type AudioContextCtor = new () => AudioContext;
function getGlobalAudioContextCtor(): AudioContextCtor | null {
const g = globalThis as typeof globalThis & {
AudioContext?: AudioContextCtor;
webkitAudioContext?: AudioContextCtor;
};
return g.AudioContext ?? g.webkitAudioContext ?? null;
}
/**
* Live `AudioContext`s in this realm. Constructing a second ENGINE is harmless
* (an engine with no context costs nothing, and `ActiveUix` creates one
* unconditionally by design) — a second live CONTEXT is the anti-pattern this
* art exists to prevent, so that is what is counted and warned about.
*/
let liveContexts = 0;
/**
* Defer through the managed scheduler when one is injected; fall back to
* `setTimeout` only when it is not (direct unit tests). Mirror of sema's
* `semaDelay` — the art keeps its own so it depends on no UIX layer.
*/
function delay(timers: SoundTimers | undefined, delayMs: number, task: () => void): void {
if (timers) {
timers.schedule(null, delayMs, task, { meta: { channel: 'sound' } });
return;
}
setTimeout(task, delayMs);
}
class SoundEngine implements EngineSound {
private audioCtx: AudioContext | null = null;
private masterGainNode: GainNode | null = null;
private readonly sampleCache = new Map<string, AudioBuffer>();
private readonly fetchFn?: typeof fetch;
private readonly audioContextFactory?: () => AudioContext | null;
private readonly dom?: SoundDom;
private masterGainValue: number;
private readonly timers: SoundTimers | undefined;
private readonly logger: Logger | undefined;
private readonly autoSuspend: boolean;
private teardownUnlock?: () => void;
private teardownVisibility?: () => void;
/** Only auto-resume what auto-suspend suspended; an explicit `suspend()` stands. */
private suspendedByVisibility = false;
private disposed = false;
constructor(opts: EngineSoundOptions = {}) {
this.fetchFn =
opts.fetchFn ?? (typeof fetch === 'function' ? fetch.bind(globalThis) : undefined);
this.audioContextFactory = opts.audioContextFactory;
this.dom = opts.dom;
this.masterGainValue = opts.masterGain ?? 1;
this.timers = opts.timers;
this.logger = opts.logger;
this.autoSuspend = opts.autoSuspend ?? false;
}
get state(): SoundEngineState {
if (!this.audioCtx) return 'absent';
return this.audioCtx.state === 'running' ? 'running' : 'suspended';
}
get context(): AudioContext | null {
return this.audioCtx;
}
prime(): void {
if (this.disposed) return;
if (this.audioCtx) {
if (this.audioCtx.state === 'suspended') this.audioCtx.resume().catch(() => {});
return;
}
try {
const ctx = this.createContext();
if (!ctx) return;
this.setupUnlockListener();
ctx.resume().catch(() => {});
} catch {
this.audioCtx = null;
this.masterGainNode = null;
}
}
async play(signature: SoundSignature, options: SoundPlayOptions = {}): Promise<void> {
if (this.disposed) return;
try {
const ctx = await this.getOrCreateContext();
if (!ctx || ctx.state !== 'running' || !this.masterGainNode) return;
// The gain lands BEFORE the graph is built: on a bare/fake context the
// synth path throws and is absorbed below, and the level must already
// be committed by then (pinned by the channel's reduction test).
this.masterGainNode.gain.value = this.masterGainValue * (options.gainScale ?? 1);
if (signature.sampleUrl) {
const played = await this.playSample(ctx, signature);
if (played) return;
}
await this.synthesize(ctx, signature);
} catch (err) {
this.logger?.debug(SOUND_LOGGER_CATEGORY, SOUND_LOGS.PLAY_FAILED, { error: err });
}
}
async preload(urls: readonly string[]): Promise<void> {
const ctx = await this.getOrCreateContext();
if (!ctx || !this.fetchFn) return;
await Promise.all(
[...new Set(urls)].map(async (url) => {
if (this.sampleCache.has(url)) return;
try {
const response = await this.fetchFn!(url);
const arrayBuffer = await response.arrayBuffer();
const buffer = await ctx.decodeAudioData(arrayBuffer);
this.sampleCache.set(url, buffer);
} catch {
// preload is opportunistic
}
})
);
}
setMasterGain(value: number): void {
this.masterGainValue = value;
if (this.masterGainNode) this.masterGainNode.gain.value = value;
}
suspend(): void {
// An explicit suspend is the caller's, not the visibility policy's.
this.suspendedByVisibility = false;
if (this.audioCtx?.state === 'running') this.audioCtx.suspend().catch(() => {});
}
resume(): void {
this.suspendedByVisibility = false;
if (this.audioCtx?.state === 'suspended') this.audioCtx.resume().catch(() => {});
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.teardownUnlock?.();
this.teardownUnlock = undefined;
this.teardownVisibility?.();
this.teardownVisibility = undefined;
if (this.audioCtx) {
this.audioCtx.close().catch(() => {});
this.audioCtx = null;
this.masterGainNode = null;
liveContexts = Math.max(0, liveContexts - 1);
}
this.sampleCache.clear();
}
// ── Internals ──────────────────────────────────────────────────────
private async getOrCreateContext(): Promise<AudioContext | null> {
if (!this.audioCtx) {
try {
if (!this.createContext()) return null;
} catch {
this.audioCtx = null;
this.masterGainNode = null;
return null;
}
}
this.setupUnlockListener();
const ctx = this.audioCtx;
if (!ctx) return null;
if (ctx.state === 'suspended') {
try {
await ctx.resume();
} catch {
return ctx;
}
}
return ctx;
}
private createContextFromGlobals(): AudioContext | null {
const Ctor = getGlobalAudioContextCtor();
return Ctor ? new Ctor() : null;
}
private createContext(): AudioContext | null {
this.audioCtx = this.audioContextFactory?.() ?? this.createContextFromGlobals();
if (!this.audioCtx) return null;
this.masterGainNode = this.audioCtx.createGain();
this.masterGainNode.gain.value = this.masterGainValue;
this.masterGainNode.connect(this.audioCtx.destination);
liveContexts++;
if (liveContexts > 1) {
this.logger?.warn(SOUND_LOGGER_CATEGORY, SOUND_LOGS.MULTIPLE_CONTEXTS, {
context: { liveContexts }
});
}
return this.audioCtx;
}
private setupUnlockListener(): void {
if (!this.dom || this.teardownUnlock) return;
// ONE `getDocument()` call for both listeners: the regression contract
// pins it at exactly one.
const doc = this.dom.getDocument();
const unlock = () => {
if (this.audioCtx?.state === 'suspended') this.audioCtx.resume().catch(() => {});
};
this.teardownUnlock = this.dom.listen(doc, SOUND_UNLOCK_EVENTS, unlock, true);
if (!this.autoSuspend) return;
const onVisibility = () => {
if (doc.hidden) {
if (this.audioCtx?.state === 'running') {
this.suspendedByVisibility = true;
this.audioCtx.suspend().catch(() => {});
}
} else if (this.suspendedByVisibility) {
this.suspendedByVisibility = false;
this.audioCtx?.resume().catch(() => {});
}
};
this.teardownVisibility = this.dom.listen(doc, SOUND_VISIBILITY_EVENT, onVisibility);
}
private async synthesize(ctx: AudioContext, sig: SoundSignature): Promise<void> {
if (!this.masterGainNode) return;
const now = ctx.currentTime;
const durationSec = sig.duration / 1000;
// Clamp attack + release so they fit within the note duration. A commit
// family note is 100ms; ramping to 75% gain at attack+decay (~128ms) —
// past the note end — produced a near-square envelope where pitch/contour
// differences between intents were inaudible. Now: attack ≤ 15% of
// duration, release ≤ 40%, sustain fills the middle.
const attackSec = Math.min(sig.attack / 1000, durationSec * 0.15);
const releaseSec = Math.min(sig.decay / 1000, durationSec * 0.4);
const sustainEndSec = Math.max(now + attackSec, now + durationSec - releaseSec);
const osc1 = ctx.createOscillator();
osc1.type = 'sine';
osc1.frequency.value = sig.pitch;
const osc2 = ctx.createOscillator();
osc2.type = 'sine';
osc2.frequency.value = sig.pitch * 1.5;
const mixer = ctx.createGain();
mixer.gain.value = 1;
const osc2Gain = ctx.createGain();
osc2Gain.gain.value = 0.3;
osc1.connect(mixer);
osc2.connect(osc2Gain);
osc2Gain.connect(mixer);
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = sig.centroid;
filter.Q.value = 1;
mixer.connect(filter);
// ADSR-lite — attack ramp, hold sustain, linear release. All ramps
// scheduled at strictly increasing times so the browser produces a clean
// envelope.
const envelope = ctx.createGain();
envelope.gain.setValueAtTime(0, now);
envelope.gain.linearRampToValueAtTime(sig.gain, now + attackSec);
envelope.gain.setValueAtTime(sig.gain, sustainEndSec);
envelope.gain.linearRampToValueAtTime(0.0001, now + durationSec);
filter.connect(envelope);
envelope.connect(this.masterGainNode);
let modulator: OscillatorNode | null = null;
if (sig.roughness > 0.2) {
modulator = ctx.createOscillator();
modulator.type = 'sine';
modulator.frequency.value = 30 + (sig.roughness - 0.2) * 150;
const modulatorGain = ctx.createGain();
modulatorGain.gain.value = sig.roughness * 0.5;
modulator.connect(modulatorGain);
modulatorGain.connect(envelope.gain);
}
this.applyContour(osc1, sig.contour, now, durationSec);
osc1.start(now);
osc2.start(now);
modulator?.start(now);
osc1.stop(now + durationSec);
osc2.stop(now + durationSec);
modulator?.stop(now + durationSec);
// Fire-and-forget: the caller doesn't await this. We still resolve after
// the duration so callers (or tests) that DO await get a natural
// completion signal. Runs on the managed scheduler.
await new Promise<void>((resolve) => {
delay(this.timers, sig.duration, resolve);
});
}
private applyContour(
osc: OscillatorNode,
contour: SoundSignature['contour'],
startTime: number,
durationSec: number
): void {
const endTime = startTime + durationSec;
// Sweep of ±400 cents = ±4 semitones. Big enough that brief notes
// (~150ms) still convey clear directional pitch motion. The previous ±50
// cents (±0.5 semitone) was technically present but imperceptible at
// typical hold durations.
const SWEEP = 400;
const HALF_SWEEP = 200;
switch (contour) {
case 'flat':
osc.detune.value = 0;
break;
case 'ascending':
osc.detune.setValueAtTime(-SWEEP, startTime);
osc.detune.linearRampToValueAtTime(SWEEP, endTime);
break;
case 'descending':
osc.detune.setValueAtTime(SWEEP, startTime);
osc.detune.linearRampToValueAtTime(-SWEEP, endTime);
break;
case 'arc':
osc.detune.setValueAtTime(-HALF_SWEEP, startTime);
osc.detune.linearRampToValueAtTime(SWEEP, startTime + durationSec * 0.5);
osc.detune.linearRampToValueAtTime(-HALF_SWEEP, endTime);
break;
case 'bell':
osc.detune.setValueAtTime(HALF_SWEEP, startTime);
osc.detune.linearRampToValueAtTime(-SWEEP, startTime + durationSec * 0.5);
osc.detune.linearRampToValueAtTime(HALF_SWEEP, endTime);
break;
}
}
/**
* Returns `true` when the sample played, `false` when fetch / decode failed
* (in which case the caller falls back to synthesis so the perceptual signal
* isn't silenced).
*/
private async playSample(ctx: AudioContext, sig: SoundSignature): Promise<boolean> {
if (!sig.sampleUrl || !this.fetchFn || !this.masterGainNode) return false;
let buffer = this.sampleCache.get(sig.sampleUrl);
if (!buffer) {
try {
const response = await this.fetchFn(sig.sampleUrl);
if (!response.ok) return false;
const arrayBuffer = await response.arrayBuffer();
buffer = await ctx.decodeAudioData(arrayBuffer);
this.sampleCache.set(sig.sampleUrl, buffer);
} catch {
return false;
}
}
if (!buffer) return false;
const source = ctx.createBufferSource();
source.buffer = buffer;
const envelope = ctx.createGain();
envelope.gain.value = sig.gain;
source.connect(envelope);
envelope.connect(this.masterGainNode);
await new Promise<void>((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
resolve();
};
source.onended = finish;
source.start();
});
return true;
}
}
/**
* Create the sound engine. No side effects: no context, no listeners until
* `prime()` / `play()` — an app that never makes a sound pays nothing.
*/
export function createEngineSound(options: EngineSoundOptions = {}): EngineSound {
return new SoundEngine(options);
}

@ -0,0 +1,12 @@
export { createEngineSound } from './engine-sound';
export type {
EngineSound,
EngineSoundOptions,
SoundContour,
SoundDom,
SoundEngineState,
SoundPlayOptions,
SoundSignature,
SoundTimers
} from './types';

@ -0,0 +1,151 @@
import type { TimerScheduler } from '$libs/timer';
import type { Logger } from '$libs/logger';
/**
* # sound — types
*
* The art owns the Web Audio MACHINERY; it knows nothing about perceptual
* doctrine. There is deliberately NO import from `$uix/sema` here: a
* `SoundSignature` is nine numeric knobs plus a contour, and sema's identically
* shaped type is structurally assignable to it. If this file ever needs a sema
* type, the cut line was drawn wrong.
*/
/**
* The DOM surface the engine needs, as a structural port — the `MotionDom` /
* `SceneDom` pattern. `ActiveDom` satisfies it; the art imports no other art.
*
* Used only to register the global unlock listener: browsers with an autoplay
* policy start the context suspended and only allow `resume()` from a user
* gesture, so the engine listens for the first one on the owner document.
*/
export interface SoundDom {
getDocument(node?: Element | Window | Node | Document | null): Document;
listen(
target: EventTarget,
event: string | readonly string[],
handler: EventListener,
options?: boolean | AddEventListenerOptions
): () => void;
}
/**
* The managed scheduler slice the engine defers on (the earcon's completion).
* `ActiveTimers` / `uix.timers` satisfies it. Declared as a `Pick` of the
* canonical `TimerScheduler` so compatibility is guaranteed by the type system
* rather than by a hand-copied signature.
*/
export type SoundTimers = Pick<TimerScheduler, 'schedule'>;
/** Pitch contour applied over the note's duration via `osc.detune`. */
export type SoundContour = 'flat' | 'ascending' | 'descending' | 'arc' | 'bell';
/**
* A playable earcon. Pure machine parameters — no family, no intent, no
* evaluative loading. Whoever calls has already resolved all of that.
*/
export interface SoundSignature {
/** Fundamental frequency, Hz. */
pitch: number;
/** Lowpass cutoff, Hz — the timbre's brightness. */
centroid: number;
/** `> 0.2` engages a fast AM modulator over the envelope. */
roughness: number;
/** Attack, ms. Clamped to ≤ 15% of `duration`. */
attack: number;
/** Release, ms. Clamped to ≤ 40% of `duration`. */
decay: number;
/** Total note length, ms. */
duration: number;
contour: SoundContour;
/** Peak envelope gain, `0..1`. */
gain: number;
/** When present, the sample is played instead of synthesising (with fallback). */
sampleUrl?: string;
}
/** Lifecycle of the underlying `AudioContext`. */
export type SoundEngineState = 'absent' | 'suspended' | 'running';
export interface EngineSoundOptions {
/**
* DOM service used to register the global unlock listener. Without it the
* engine still plays, but a context suspended by the autoplay policy is only
* resumed on the next `prime()` / `play()` instead of on the next gesture.
*/
dom?: SoundDom;
/**
* Override the `AudioContext` factory (tests / non-browser environments).
* This is the seam that keeps the whole engine testable without Web Audio.
*/
audioContextFactory?: () => AudioContext | null;
/** Override the global `fetch` (sample loading in tests). */
fetchFn?: typeof fetch;
/**
* Master gain multiplier applied on top of every signature's own `gain`.
* Written onto the master node at context creation. Default `1`.
*/
masterGain?: number;
/**
* Managed scheduler the earcon-completion timer runs on. Falls back to
* `setTimeout` only when absent (direct unit tests); production always
* injects `uix.timers`.
*/
timers?: SoundTimers;
/**
* Suspend the context while the tab is hidden, resume when it comes back.
* **Opt-in, `false` by default — on purpose.**
*
* Suspending is the right policy for UI earcons and the WRONG one for
* content: people listen to podcasts with the tab hidden, and a media player
* hangs its graph off this same context. So it is a decision of whoever
* composes, never of the engine. Needs `dom` (it listens for
* `visibilitychange`). Only auto-resumes what it auto-suspended — an
* explicit `suspend()` is respected.
*/
autoSuspend?: boolean;
/** Optional diagnostics logger. Without it, audio failures stay silent. */
logger?: Logger;
}
export interface SoundPlayOptions {
/**
* Multiplier applied to `masterGain` for THIS playback, `0..1`. The caller's
* policy lives here: sema passes its reduction factor, a media player could
* pass a ducking factor. The engine only multiplies.
*/
gainScale?: number;
}
/**
* The Web Audio runtime. `Engine*` (not `Active*`): public methods over private
* state, no runes — same shape as `EngineMotion` / `EngineScene`.
*/
export interface EngineSound {
/** Lifecycle of the underlying context. `'absent'` = not created yet. */
readonly state: SoundEngineState;
/**
* The raw output. Exposed on purpose: whoever needs their own graph (an
* `AnalyserNode`, gain > 1, a `BufferSource`, sample-accurate scheduling)
* hangs it off THIS context instead of opening a second one — the browser
* caps them and the unlock gesture is per-context.
*/
readonly context: AudioContext | null;
/**
* Create + resume the context SYNCHRONOUSLY, from inside a user gesture.
* The autoplay policy only honours `resume()` on the gesture's own turn, so
* this must not be awaited before being called.
*/
prime(): void;
/** Play an earcon. Never rejects — audio is ornamental. */
play(signature: SoundSignature, options?: SoundPlayOptions): Promise<void>;
/** Pre-decode samples into the cache so the first play pays no fetch+decode. */
preload(urls: readonly string[]): Promise<void>;
/** Update the master gain (and the live node, when the context exists). */
setMasterGain(value: number): void;
suspend(): void;
resume(): void;
/** Idempotent: tear down the unlock listener, close the context, clear the cache. */
dispose(): void;
}

@ -37,6 +37,7 @@ import {
} from '$adom';
import { EngineSemantic } from '$uix/sema';
import { createEngineMotion, type EngineMotion } from '$motion';
import { createEngineSound, type EngineSound, type SoundDom } from '$sound';
import { createEngineScene, type EngineScene, type SceneDom } from '$scene';
import * as colorEngine from '$color';
import { createActivePerf, type ActivePerf } from '$perf';
@ -113,6 +114,18 @@ export function createActiveUix(options: ActiveUixOptions): ActiveUix {
})
: undefined;
// Sound runtime — created UNCONDITIONALLY, and that is free: the engine has
// no constructor side effects (no AudioContext, no listeners until the first
// `prime()`), so an app that never makes a sound pays nothing. Creating it
// here rather than letting each consumer make its own is the whole point of
// the art: ONE `AudioContext` per document, because the browser caps them
// and the autoplay unlock gesture is per-context.
const sound = createEngineSound({
dom: dom as SoundDom | undefined,
timers,
logger
});
const eventsOpts =
typeof options.events === 'object' && options.events !== null ? options.events : {};
const events =
@ -121,6 +134,9 @@ export function createActiveUix(options: ActiveUixOptions): ActiveUix {
...eventsOpts,
logger: eventsOpts.logger ?? logger,
dom: eventsOpts.dom ?? runtimeDom,
// The shared Web Audio runtime: sema's sound channel plays THROUGH
// it instead of opening its own context.
soundEngine: eventsOpts.soundEngine ?? sound,
// Route every perceptual timer (visual hold / haptic delay /
// earcon completion) through the managed clock instead of a
// raw setTimeout.
@ -160,6 +176,7 @@ export function createActiveUix(options: ActiveUixOptions): ActiveUix {
events,
motion,
scene,
sound,
perf,
portal: options.portal,
detachLangsPrefs
@ -186,6 +203,16 @@ export function attachActiveUix(app: ActiveApp, options: AttachActiveUixOptions
const appScene = (app as unknown as { scene?: EngineScene }).scene;
const scene = appScene ?? createEngineScene({ dom: appDom as unknown as SceneDom });
const ownsScene = appScene === undefined;
// Sound: same shape as motion / scene — the app's engine when declared
// (`defineEngineSound()`), else a fallback bound to the app's dom.
//
// NOTE for attach integrators: in attach the app also builds its own
// `EngineSemantic`. To keep ONE context, pass this engine to it
// (`soundEngine: App.sound`); otherwise sema's channel creates a private
// one and the document ends up with two.
const appSound = (app as unknown as { sound?: EngineSound }).sound;
const sound = appSound ?? createEngineSound({ dom: appDom as unknown as SoundDom });
const ownsSound = appSound === undefined;
const perf = options.reflowDetector ? createActivePerf() : undefined;
if (options.registerDefaultLangs ?? true) {
@ -202,6 +229,8 @@ export function attachActiveUix(app: ActiveApp, options: AttachActiveUixOptions
ownsMotion,
scene,
ownsScene,
sound,
ownsSound,
perf,
portal: options.portal,
detachLangsPrefs: undefined
@ -224,6 +253,7 @@ interface StandaloneInit {
events: EngineSemantic | undefined;
motion: EngineMotion;
scene: EngineScene | undefined;
sound: EngineSound;
perf: ActivePerf | undefined;
portal: string | HTMLElement | undefined;
detachLangsPrefs: (() => void) | undefined;
@ -240,6 +270,9 @@ interface AttachInit {
scene: EngineScene;
/** active-uix created the scene engine as a fallback (the app didn't declare one). */
ownsScene: boolean;
sound: EngineSound;
/** active-uix created the sound engine as a fallback (the app didn't declare one). */
ownsSound: boolean;
perf: ActivePerf | undefined;
portal: string | HTMLElement | undefined;
detachLangsPrefs: (() => void) | undefined;
@ -422,6 +455,18 @@ class ActiveUixImpl implements ActiveUix {
return this.init.scene;
}
/**
* The shared Web Audio runtime (`$sound`). Always present — the engine has
* no constructor side effects, so an app that never makes a sound pays
* nothing. Every consumer meets here: sema's sound channel plays through it,
* and anything needing its own graph (an analyser, gain > 1, a buffer
* source) hangs it off `uix.sound.context` instead of opening a second
* `AudioContext`.
*/
get sound(): EngineSound {
return this.init.sound;
}
/**
* Colour math engine (`arts/color`). Pure + stateless, so it's the same in both
* boot modes — returns the `$color` namespace (`oklchToHex`, `parseColor`,
@ -539,12 +584,15 @@ class ActiveUixImpl implements ActiveUix {
// dependency order: format → events/sema → motion → dom → disabledDom →
// clipboard → langs → prefs → timers → bus → logger.
if (this.init.mode === 'standalone') {
const { format, events, motion, scene, dom, disabledDom, clipboard, langs, prefs, timers, bus, logger } =
const { format, events, motion, scene, sound, dom, disabledDom, clipboard, langs, prefs, timers, bus, logger } =
this.init;
format?.dispose();
// events first: its channels release their references to the sound
// engine before we close the context underneath them.
events?.dispose();
scene?.dispose();
motion.dispose();
sound.dispose();
dom?.dispose();
disabledDom?.dispose();
clipboard?.dispose();
@ -558,6 +606,7 @@ class ActiveUixImpl implements ActiveUix {
// created as fallbacks because the app did not declare them.
if (this.init.ownsScene) this.init.scene.dispose();
if (this.init.ownsMotion) this.init.motion.dispose();
if (this.init.ownsSound) this.init.sound.dispose();
}
// Attach mode: the app is owned by the application, not by us.

@ -21,6 +21,7 @@ import type { PrefsEnvironment, PrefsIntentOf, PrefsSchema } from '$libs/prefs';
import type { EngineSemantic, EngineSemanticOptions } from '$uix/sema';
import type { EngineMotion } from '$motion';
import type { EngineScene } from '$scene';
import type { EngineSound } from '$sound';
/**
* Langs configuration. Required because every UIX consumer needs at
@ -137,6 +138,20 @@ export interface ActiveUix {
*/
readonly scene: EngineScene | undefined;
/**
* Web Audio runtime (`arts/sound` / `$sound`). **Always present** — the
* engine has no constructor side effects (no `AudioContext`, no listeners
* until the first `prime()`), so an app that never makes a sound pays
* nothing.
*
* ONE engine per document on purpose: browsers cap concurrent
* `AudioContext`s and the autoplay unlock gesture is per-context, so a
* second one would leave either the UI earcons or the app's own audio mute.
* sema's sound channel plays THROUGH it; anything needing its own graph (an
* `AnalyserNode`, gain > 1, a `BufferSource`) hangs it off `sound.context`.
*/
readonly sound: EngineSound;
/**
* Colour math engine (`arts/color` / `$color`). Pure + stateless — OKLCH⇄sRGB,
* APCA contrast, scale generation, scheme derivation. The discoverable runtime

@ -12,6 +12,7 @@ import type { ActiveTimers } from '$timer';
import type { EngineSemantic } from './sema';
import type { EngineMotion } from '$motion';
import type { EngineScene } from '$scene';
import type { EngineSound } from '$sound';
import type { SomaRuntime } from './soma/runtime.svelte';
import type { ActiveUix } from './active-uix/types';
import type { UixRequiredService } from './active-uix/services';
@ -22,6 +23,7 @@ export interface ActiveUixServiceContract {
readonly dom: ActiveDom;
readonly motion: EngineMotion;
readonly scene: EngineScene | undefined;
readonly sound: EngineSound;
readonly clipboard: ActiveClipboard;
readonly events: EngineSemantic | undefined;
readonly logger: EngineLogger;
@ -94,6 +96,27 @@ export interface UixLayerContractTable {
readonly requiresOneOfForVisualProjection: readonly ['dom', 'projector'];
readonly createsServices: readonly [];
};
/**
* The Web Audio runtime (`$sound`). Requires nothing: both dependencies are
* optional ports and both degrade — without `dom` a context suspended by the
* autoplay policy resumes on the next `prime()` instead of on the next
* gesture; without `timers` the earcon's completion falls back to
* `setTimeout`. Without an `AudioContext` at all it is silently inert, which
* is why it can be created unconditionally.
*
* `singleContextPerDocument` is the invariant the art exists for: browsers
* cap concurrent contexts and the unlock gesture is per-context, so every
* consumer takes THIS engine (sema via `soundEngine`, everyone else via
* `uix.sound.context`) instead of opening its own.
*/
readonly sound: {
readonly role: 'web-audio-runtime';
readonly publicServiceName: 'sound';
readonly optional: readonly ['dom', 'timers'];
readonly degradation: 'silent';
readonly singleContextPerDocument: true;
readonly createsServices: readonly [];
};
readonly activeEidos: {
readonly role: 'visual-runtime';
readonly publicRuntime: 'ActiveEidos';
@ -117,7 +140,8 @@ export const UIX_LAYER_CONTRACTS = {
'dom',
'clipboard',
'format',
'events'
'events',
'sound'
],
attachRequires: UIX_REQUIRED_SERVICES,
publicSurface: [
@ -126,6 +150,7 @@ export const UIX_LAYER_CONTRACTS = {
'dom',
'motion',
'scene',
'sound',
'clipboard',
'events',
'logger',
@ -178,6 +203,14 @@ export const UIX_LAYER_CONTRACTS = {
requiresOneOfForVisualProjection: ['dom', 'projector'],
createsServices: []
},
sound: {
role: 'web-audio-runtime',
publicServiceName: 'sound',
optional: ['dom', 'timers'],
degradation: 'silent',
singleContextPerDocument: true,
createsServices: []
},
activeEidos: {
role: 'visual-runtime',
publicRuntime: 'ActiveEidos',

@ -0,0 +1,132 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest';
import { EngineSemantic } from '../engine';
/**
* End-to-end guard of the audio path: `EngineSemantic.emit(...)` → cascade →
* `SoundChannel` → `$sound` → real oscillator nodes.
*
* Every other sema suite stops at a fake channel, so the extraction could have
* broken the actual graph with all 184 tests green. This is the test that was
* missing — it mirrors what the sema studio (`/temas/sema`) does: build an
* engine with `sound: true` and fire.
*/
/**
* Immediate scheduler: the earcon resolves synchronously so the test doesn't
* wait out the note's real duration.
*/
const immediateTimers = {
schedule: (_key: string | null, _delayMs: number, task: () => void) => {
task();
return { cancel: () => true };
}
} as never;
function createAudioSpy() {
const nodes = { oscillators: 0, gains: 0, filters: 0, started: 0 };
const param = () => ({
value: 0,
setValueAtTime: vi.fn(),
linearRampToValueAtTime: vi.fn()
});
const gainNode = () => {
nodes.gains++;
return { gain: param(), connect: vi.fn() };
};
const ctx = {
state: 'running',
currentTime: 0,
destination: {},
createGain: vi.fn(gainNode),
createOscillator: vi.fn(() => {
nodes.oscillators++;
return {
type: '',
frequency: { value: 0 },
detune: param(),
connect: vi.fn(),
start: vi.fn(() => nodes.started++),
stop: vi.fn()
};
}),
createBiquadFilter: vi.fn(() => {
nodes.filters++;
return { type: '', frequency: { value: 0 }, Q: { value: 0 }, connect: vi.fn() };
}),
resume: vi.fn(() => Promise.resolve()),
close: vi.fn(() => Promise.resolve())
} as unknown as AudioContext;
return { ctx, nodes };
}
describe('audio path: EngineSemantic → SoundChannel → $sound', () => {
it('builds a real oscillator graph for an audible signal', async () => {
const audio = createAudioSpy();
const engine = new EngineSemantic({
visual: false,
sound: { audioContextFactory: () => audio.ctx },
timers: immediateTimers
});
await engine.emit({
target: document.createElement('button'),
name: 'commit-save',
family: 'commit',
intent: 'affirm',
channels: ['sound']
});
// Two oscillators (sine + a fifth) + the biquad + the envelope chain.
expect(audio.nodes.oscillators).toBeGreaterThanOrEqual(2);
expect(audio.nodes.filters).toBeGreaterThanOrEqual(1);
expect(audio.nodes.started).toBeGreaterThanOrEqual(2);
engine.dispose();
});
it('stays silent when the signal excludes the sound channel', async () => {
const audio = createAudioSpy();
const engine = new EngineSemantic({
visual: false,
sound: { audioContextFactory: () => audio.ctx }
});
await engine.emit({
target: document.createElement('button'),
name: 'commit-save',
family: 'commit',
intent: 'affirm',
channels: ['haptic']
});
expect(audio.nodes.oscillators).toBe(0);
engine.dispose();
});
it('rebuilding the engine (the studio\'s hot-reload) keeps producing sound', async () => {
// `/temas/sema` disposes and rebuilds its EngineSemantic on every draft
// edit. A disposed engine must not poison the next one.
for (let i = 0; i < 3; i++) {
const audio = createAudioSpy();
const engine = new EngineSemantic({
visual: false,
sound: { audioContextFactory: () => audio.ctx },
timers: immediateTimers
});
await engine.emit({
target: document.createElement('button'),
name: 'commit-save',
family: 'commit',
intent: 'affirm',
channels: ['sound']
});
expect(audio.nodes.oscillators, `rebuild #${i}`).toBeGreaterThanOrEqual(2);
engine.dispose();
}
});
});

@ -0,0 +1,127 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest';
import { SoundChannel } from './sound';
import type { SoundSignature as SemaSoundSignature } from '../channels';
import type { EngineSound, SoundSignature as ArtSoundSignature } from '$sound';
/**
* The seam between sema and the `$sound` art.
*
* Kept OUT of `sound.test.ts` on purpose: that file is the regression contract
* of the extraction (RC-1…RC-6) and must stay byte-identical, so the "green
* without touching a single assert" criterion means something. This file pins
* what the extraction ADDED.
*/
// ── E-6 · type drift guard ─────────────────────────────────────────────────
// `SoundSignature` is declared twice on purpose: the art must not import from
// `$uix/sema` (the objective test of the cut line). Structural assignability
// makes that work — and makes it SILENT in the dangerous direction: if sema
// grows a field (`pan`), the art would just ignore it, with no type error.
// Mutual assignability turns that drift into a compile failure here.
type Exact<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
const _signaturesStayIdentical: Exact<SemaSoundSignature, ArtSoundSignature> = true;
void _signaturesStayIdentical;
// The DOM port needs no guard: `SoundChannelDom` is a straight alias of the
// art's `SoundDom`, so there is only one definition.
function createFakeEngine() {
const engine: EngineSound = {
state: 'absent',
context: null,
prime: vi.fn(),
play: vi.fn(async () => {}),
preload: vi.fn(async () => {}),
setMasterGain: vi.fn(),
suspend: vi.fn(),
resume: vi.fn(),
dispose: vi.fn()
};
return engine;
}
const effective = {
family: 'commit',
intent: undefined,
activeChannels: ['sound'],
sound: {
pitch: 700,
centroid: 1800,
roughness: 0.1,
attack: 8,
decay: 120,
duration: 100,
contour: 'flat',
gain: 0.3
}
} as const;
const signal = { target: null, name: 'x', family: 'commit' } as never;
describe('SoundChannel ↔ $sound seam', () => {
it('plays THROUGH the injected engine instead of opening its own context', async () => {
const engine = createFakeEngine();
const channel = new SoundChannel({ engine });
channel.prepare?.({ target: document.createElement('button'), name: 'open' });
expect(engine.prime).toHaveBeenCalledTimes(1);
await channel.handle(signal, effective as never);
expect(engine.play).toHaveBeenCalledWith(effective.sound, { gainScale: 1 });
});
it('resolves the reduction LEVEL and lets the engine apply the gain', async () => {
const engine = createFakeEngine();
const channel = new SoundChannel({ engine, preferences: { sound: 'reduce' } });
await channel.handle(signal, effective as never);
expect(engine.play).toHaveBeenCalledWith(effective.sound, { gainScale: 0.4 });
});
it('never reaches the engine when the level is "off"', async () => {
const engine = createFakeEngine();
const channel = new SoundChannel({ engine, preferences: { sound: 'off' } });
await channel.handle(signal, effective as never);
expect(engine.play).not.toHaveBeenCalled();
});
// ── E-1 · the ownership rule ────────────────────────────────────────────
it('does NOT dispose an injected engine (it belongs to the composition root)', () => {
const engine = createFakeEngine();
const channel = new SoundChannel({ engine });
channel.dispose();
expect(engine.dispose).not.toHaveBeenCalled();
});
it('DOES dispose the engine it created itself', () => {
const close = vi.fn(() => Promise.resolve());
const ctx = {
state: 'suspended',
destination: {},
createGain: vi.fn(() => ({ gain: { value: 0 }, connect: vi.fn() })),
resume: vi.fn(() => Promise.resolve()),
close
} as unknown as AudioContext;
const channel = new SoundChannel({ audioContextFactory: () => ctx });
channel.prepare?.({ target: document.createElement('button'), name: 'open' });
channel.dispose();
expect(close).toHaveBeenCalledTimes(1);
});
it('delegates sample preloading to the engine', async () => {
const engine = createFakeEngine();
const channel = new SoundChannel({ engine });
await channel.preloadSamples(['/a.wav', '/b.wav']);
expect(engine.preload).toHaveBeenCalledWith(['/a.wav', '/b.wav']);
});
});

@ -1,48 +1,48 @@
import type { EffectiveSignature, SoundSignature } from '../resolver';
import type { EffectiveSignature } from '../resolver';
import type { SemanticSignal } from '../signal';
import { semaDelay, type SemaTimerScheduler } from '../timers';
import type { SemaTimerScheduler } from '../timers';
import type { Channel, ChannelPreparation } from './types';
import type { SemaPreferences } from '../channels';
import type { Logger } from '$libs/logger';
import { createEngineSound, type EngineSound, type SoundDom } from '$sound';
/**
* Canal sonoro. Materializa la señal como un earcon corto sintetizado vía
* Web Audio API a partir de la `SoundSignature` resuelta por el engine
* (`effective.sound`). Si la familia no incluye `'sound'` en
* `activeChannels` o no hay `sound` resuelto, el canal hace no-op.
* Canal sonoro. Materializa la señal como un earcon corto a partir de la
* `SoundSignature` resuelta por el engine (`effective.sound`). Si la familia no
* incluye `'sound'` en `activeChannels` o no hay `sound` resuelto, hace no-op.
*
* Comportamiento:
* - Mantiene un único `AudioContext` con un `GainNode` master.
* - En navegadores con autoplay restriction (Safari, iOS), el contexto
* arranca suspendido y se hace `resume()` cuando una señal sonora se
* prepara desde el gesto de usuario. El listener global de unlock se
* registra despues de crear el contexto, no en el constructor.
* - Síntesis: dos osciladores (sine + 5ª) → biquad lowpass (centroid)
* → envelope ADSR-lite (attack/decay/sustain a 0.75 → release a
* ~0). Si `roughness > 0.2`, modulador AM rápido sobre la envelope.
* Contour aplicado vía `osc.detune` (flat / ascending / descending /
* arc / bell).
* - Si la signature trae `sampleUrl`, reproduce el sample (con caché
* de `AudioBuffer`) en lugar de sintetizar.
* **La máquina Web Audio NO vive aquí.** El contexto, la síntesis, el desbloqueo
* por gesto y la reproducción de samples se extrajeron al art `$sound`
* (`EngineSound`) el 2026-07-30: eran ~63% de este fichero y son maquinaria, no
* doctrina perceptiva. Lo que queda es exactamente la doctrina:
*
* Errores: cualquier fallo (no-AudioContext, decode failure, etc.) se
* absorbe silenciosamente — sema es ornamental, un fallo de audio no debe
* abortar la operación del provider.
* - el gate de `prepare` (¿esta señal admite sonido?),
* - la política de reducción por canal (BK-REDUCTIONS): `off` silencia — el
* significado migra vía `SEMA_MIGRATION.sound` → presencia / live region —
* y `reduce` atenúa,
* - y el reparto de responsabilidad: el canal resuelve el NIVEL y el art
* aplica la ganancia.
*
* **Propiedad del engine**: quien lo crea, lo dispone. Si la raíz de composición
* inyecta uno compartido (`uix.sound`), el canal NO lo cierra — cerrar un
* contexto ajeno dejaría mudo al resto del documento. Mismo idioma que
* `ownsMotion` / `ownsScene` en `active-uix`.
*/
type AudioContextCtor = new () => AudioContext;
export interface SoundChannelDom {
getDocument(node?: Element | Window | Node | Document | null): Document;
listen(
target: EventTarget,
event: string | readonly string[],
handler: EventListener,
options?: boolean | AddEventListenerOptions
): () => void;
}
/**
* The DOM surface the unlock listener needs. Alias of the art's port so there is
* ONE definition: the channel forwards it untouched.
*/
export type SoundChannelDom = SoundDom;
export interface SoundChannelOptions {
/**
* The shared Web Audio runtime. Injected by the composition root
* (`uix.sound`) so the whole document meets at ONE `AudioContext`. When
* absent the channel creates a private engine and owns its lifecycle —
* the direct-construction / unit-test path.
*/
engine?: EngineSound;
/** Override the AudioContext factory (tests / non-browser environments). */
audioContextFactory?: () => AudioContext | null;
/** Override the global fetch (for sample loading in tests). */
@ -59,7 +59,7 @@ export interface SoundChannelOptions {
preferences?: SemaPreferences;
/**
* Managed scheduler (`uix.timers`) the earcon-completion timer runs on.
* Injected by the engine; falls back to `setTimeout` only when absent
* Forwarded to the engine; it falls back to `setTimeout` only when absent
* (unit tests).
*/
timers?: SemaTimerScheduler;
@ -67,46 +67,36 @@ export interface SoundChannelOptions {
logger?: Logger;
}
function getGlobalAudioContextCtor(): AudioContextCtor | null {
const g = globalThis as typeof globalThis & {
AudioContext?: AudioContextCtor;
webkitAudioContext?: AudioContextCtor;
};
return g.AudioContext ?? g.webkitAudioContext ?? null;
}
/** Master-gain multiplier applied when `preferences.sound === 'reduce'`. */
const SOUND_REDUCE_GAIN_FACTOR = 0.4;
export class SoundChannel implements Channel {
readonly id = 'sound';
private audioCtx: AudioContext | null = null;
private masterGainNode: GainNode | null = null;
private readonly sampleCache = new Map<string, AudioBuffer>();
private readonly fetchFn?: typeof fetch;
private readonly audioContextFactory?: () => AudioContext | null;
private readonly dom?: SoundChannelDom;
private readonly masterGainValue: number;
private readonly engine: EngineSound;
private readonly ownsEngine: boolean;
private readonly preferences?: SemaPreferences;
private readonly timers: SemaTimerScheduler | undefined;
private readonly logger: Logger | undefined;
private teardownUnlock?: () => void;
constructor(opts: SoundChannelOptions = {}) {
this.fetchFn =
opts.fetchFn ?? (typeof fetch === 'function' ? fetch.bind(globalThis) : undefined);
this.audioContextFactory = opts.audioContextFactory;
this.dom = opts.dom;
this.masterGainValue = opts.masterGain ?? 1;
this.preferences = opts.preferences;
this.timers = opts.timers;
this.logger = opts.logger;
this.ownsEngine = opts.engine === undefined;
this.engine =
opts.engine ??
createEngineSound({
dom: opts.dom,
audioContextFactory: opts.audioContextFactory,
fetchFn: opts.fetchFn,
masterGain: opts.masterGain,
timers: opts.timers,
logger: opts.logger
});
}
prepare(signal: SemanticSignal): ChannelPreparation | undefined {
if (!this.shouldPrime(signal)) return undefined;
this.primeContextFromGesture();
// Synchronous, inside the user gesture: the autoplay policy only honours
// `resume()` on the gesture's own turn.
this.engine.prime();
return undefined;
}
@ -120,34 +110,15 @@ export class SoundChannel implements Channel {
// SEMA_MIGRATION.sound → presence / live region); `reduce` attenuates.
const level = this.preferences?.sound ?? 'full';
if (level === 'off') return;
const reduceFactor = level === 'reduce' ? SOUND_REDUCE_GAIN_FACTOR : 1;
try {
const ctx = await this.getOrCreateContext();
if (!ctx || ctx.state !== 'running' || !this.masterGainNode) return;
const gainScale = level === 'reduce' ? SOUND_REDUCE_GAIN_FACTOR : 1;
this.masterGainNode.gain.value = this.masterGainValue * reduceFactor;
if (sig.sampleUrl) {
const played = await this.playSample(ctx, sig);
if (played) return;
}
await this.synthesize(ctx, sig);
} catch (err) {
// Sema es ornamental: silenciar fallos de audio.
this.logger?.debug('sema.sound', 'handle failed', { error: err });
}
// The engine never rejects — audio is ornamental.
await this.engine.play(sig, { gainScale });
}
dispose(): void {
this.teardownUnlock?.();
this.teardownUnlock = undefined;
if (this.audioCtx) {
this.audioCtx.close().catch(() => {});
this.audioCtx = null;
this.masterGainNode = null;
}
this.sampleCache.clear();
// Quien crea, dispone: a shared engine belongs to the composition root.
if (this.ownsEngine) this.engine.dispose();
}
/**
@ -155,253 +126,11 @@ export class SoundChannel implements Channel {
* first-play latency of fetch + decode.
*/
async preloadSamples(urls: string[]): Promise<void> {
const ctx = await this.getOrCreateContext();
if (!ctx || !this.fetchFn) return;
await Promise.all(
[...new Set(urls)].map(async (url) => {
if (this.sampleCache.has(url)) return;
try {
const response = await this.fetchFn!(url);
const arrayBuffer = await response.arrayBuffer();
const buffer = await ctx.decodeAudioData(arrayBuffer);
this.sampleCache.set(url, buffer);
} catch {
// preload is opportunistic
}
})
);
}
// ── Internals ──────────────────────────────────────────────────────
private async getOrCreateContext(): Promise<AudioContext | null> {
if (!this.audioCtx) {
try {
if (!this.createContext()) return null;
} catch {
this.audioCtx = null;
this.masterGainNode = null;
return null;
}
}
this.setupUnlockListener();
const ctx = this.audioCtx;
if (!ctx) return null;
if (ctx.state === 'suspended') {
try {
await ctx.resume();
} catch {
return ctx;
}
}
return ctx;
}
private createContextFromGlobals(): AudioContext | null {
const Ctor = getGlobalAudioContextCtor();
return Ctor ? new Ctor() : null;
}
private createContext(): AudioContext | null {
this.audioCtx = this.audioContextFactory?.() ?? this.createContextFromGlobals();
if (!this.audioCtx) return null;
this.masterGainNode = this.audioCtx.createGain();
this.masterGainNode.gain.value = this.masterGainValue;
this.masterGainNode.connect(this.audioCtx.destination);
return this.audioCtx;
await this.engine.preload(urls);
}
private shouldPrime(signal: SemanticSignal): boolean {
if (signal.channels !== undefined) return signal.channels.includes('sound');
return true;
}
private primeContextFromGesture(): void {
if (this.audioCtx) {
if (this.audioCtx.state === 'suspended') this.audioCtx.resume().catch(() => {});
return;
}
try {
const ctx = this.createContext();
if (!ctx) return;
this.setupUnlockListener();
ctx.resume().catch(() => {});
} catch {
this.audioCtx = null;
this.masterGainNode = null;
}
}
private setupUnlockListener(): void {
if (!this.dom || this.teardownUnlock) return;
const events = ['pointerdown', 'mousedown', 'click', 'touchstart', 'keydown'] as const;
const unlock = () => {
if (this.audioCtx?.state === 'suspended') this.audioCtx.resume().catch(() => {});
};
this.teardownUnlock = this.dom.listen(this.dom.getDocument(), events, unlock, true);
}
private async synthesize(ctx: AudioContext, sig: SoundSignature): Promise<void> {
if (!this.masterGainNode) return;
const now = ctx.currentTime;
const durationSec = sig.duration / 1000;
// Clamp attack + release so they fit within the note duration. A
// commit family note is 100ms; the original code ramped to 75% gain
// at attack+decay (~128ms) — past the note end — which produced a
// near-square envelope where pitch/contour differences between
// intents were inaudible. Now: attack ≤ 15% of duration, release ≤
// 40%, sustain fills the middle.
const attackSec = Math.min(sig.attack / 1000, durationSec * 0.15);
const releaseSec = Math.min(sig.decay / 1000, durationSec * 0.4);
const sustainEndSec = Math.max(now + attackSec, now + durationSec - releaseSec);
const osc1 = ctx.createOscillator();
osc1.type = 'sine';
osc1.frequency.value = sig.pitch;
const osc2 = ctx.createOscillator();
osc2.type = 'sine';
osc2.frequency.value = sig.pitch * 1.5;
const mixer = ctx.createGain();
mixer.gain.value = 1;
const osc2Gain = ctx.createGain();
osc2Gain.gain.value = 0.3;
osc1.connect(mixer);
osc2.connect(osc2Gain);
osc2Gain.connect(mixer);
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = sig.centroid;
filter.Q.value = 1;
mixer.connect(filter);
// ADSR-lite — attack ramp, hold sustain, linear release. All ramps
// scheduled at strictly increasing times so the browser produces a
// clean envelope.
const envelope = ctx.createGain();
envelope.gain.setValueAtTime(0, now);
envelope.gain.linearRampToValueAtTime(sig.gain, now + attackSec);
envelope.gain.setValueAtTime(sig.gain, sustainEndSec);
envelope.gain.linearRampToValueAtTime(0.0001, now + durationSec);
filter.connect(envelope);
envelope.connect(this.masterGainNode);
let modulator: OscillatorNode | null = null;
if (sig.roughness > 0.2) {
modulator = ctx.createOscillator();
modulator.type = 'sine';
modulator.frequency.value = 30 + (sig.roughness - 0.2) * 150;
const modulatorGain = ctx.createGain();
modulatorGain.gain.value = sig.roughness * 0.5;
modulator.connect(modulatorGain);
modulatorGain.connect(envelope.gain);
}
this.applyContour(osc1, sig.contour, now, durationSec);
osc1.start(now);
osc2.start(now);
modulator?.start(now);
osc1.stop(now + durationSec);
osc2.stop(now + durationSec);
modulator?.stop(now + durationSec);
// Fire-and-forget: the engine doesn't await this. We still resolve
// after the duration so callers (or tests) that DO await get a
// natural completion signal. Runs on the managed scheduler.
await new Promise<void>((resolve) => {
semaDelay(this.timers, sig.duration, resolve, { channel: 'sound' });
});
}
private applyContour(
osc: OscillatorNode,
contour: SoundSignature['contour'],
startTime: number,
durationSec: number
): void {
const endTime = startTime + durationSec;
// Sweep amount of ±400 cents = ±4 semitones. Big enough that brief
// notes (~150ms) still convey clear directional pitch motion. The
// previous ±50 cents (±0.5 semitone) was technically present but
// imperceptible at typical hold durations.
const SWEEP = 400;
const HALF_SWEEP = 200;
switch (contour) {
case 'flat':
osc.detune.value = 0;
break;
case 'ascending':
osc.detune.setValueAtTime(-SWEEP, startTime);
osc.detune.linearRampToValueAtTime(SWEEP, endTime);
break;
case 'descending':
osc.detune.setValueAtTime(SWEEP, startTime);
osc.detune.linearRampToValueAtTime(-SWEEP, endTime);
break;
case 'arc':
osc.detune.setValueAtTime(-HALF_SWEEP, startTime);
osc.detune.linearRampToValueAtTime(SWEEP, startTime + durationSec * 0.5);
osc.detune.linearRampToValueAtTime(-HALF_SWEEP, endTime);
break;
case 'bell':
osc.detune.setValueAtTime(HALF_SWEEP, startTime);
osc.detune.linearRampToValueAtTime(-SWEEP, startTime + durationSec * 0.5);
osc.detune.linearRampToValueAtTime(HALF_SWEEP, endTime);
break;
}
}
/**
* Returns `true` when the sample played, `false` when fetch / decode
* failed (in which case the caller should fall back to synthesis so
* the perceptual signal isn't silenced).
*/
private async playSample(ctx: AudioContext, sig: SoundSignature): Promise<boolean> {
if (!sig.sampleUrl || !this.fetchFn || !this.masterGainNode) return false;
let buffer = this.sampleCache.get(sig.sampleUrl);
if (!buffer) {
try {
const response = await this.fetchFn(sig.sampleUrl);
if (!response.ok) return false;
const arrayBuffer = await response.arrayBuffer();
buffer = await ctx.decodeAudioData(arrayBuffer);
this.sampleCache.set(sig.sampleUrl, buffer);
} catch {
return false;
}
}
if (!buffer) return false;
const source = ctx.createBufferSource();
source.buffer = buffer;
const envelope = ctx.createGain();
envelope.gain.value = sig.gain;
source.connect(envelope);
envelope.connect(this.masterGainNode);
await new Promise<void>((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
resolve();
};
source.onended = finish;
source.start();
});
return true;
}
}

@ -27,6 +27,7 @@ import type { DomApplier } from '$adom';
import type { Logger } from '$libs/logger';
import { HapticChannel, type HapticChannelDom, type HapticChannelOptions } from './chans/haptic';
import { SoundChannel, type SoundChannelDom, type SoundChannelOptions } from './chans/sound';
import type { EngineSound } from '$sound';
import { VisualChannel, type VisualChannelOptions } from './chans/visual';
import {
AnnounceChannel,
@ -65,6 +66,16 @@ export interface EngineSemanticOptions {
visual?: false | VisualChannelOptions | Channel;
/** SoundChannel built-in. `true`/object/Channel to enable; `false`/undef to skip. */
sound?: true | false | SoundChannelOptions | Channel;
/**
* The shared Web Audio runtime (`$sound`), injected by the composition root
* as `uix.sound`. Forwarded to the built-in `SoundChannel` so the whole
* document meets at ONE `AudioContext`: the browser caps them and the
* autoplay unlock gesture is per-context, so a second engine would leave
* one of the two mute. The engine is NOT owned here — whoever created it
* disposes it (`ownsSound` in `active-uix`). When absent, the channel
* creates and owns a private one (direct construction / unit tests).
*/
soundEngine?: EngineSound;
/** HapticChannel built-in. `true`/object/Channel to enable; `false`/undef to skip. */
haptic?: true | false | HapticChannelOptions | Channel;
/**
@ -283,6 +294,7 @@ export class EngineSemantic {
const soundOptions = opts.sound === true ? {} : opts.sound;
soundChannel = new SoundChannel({
...soundOptions,
engine: soundOptions.engine ?? opts.soundEngine,
dom: soundOptions.dom ?? (isSoundChannelDom(opts.dom) ? opts.dom : undefined),
timers: soundOptions.timers ?? opts.timers,
preferences: soundOptions.preferences ?? opts.preferences,
@ -292,9 +304,17 @@ export class EngineSemantic {
this.register(soundChannel);
// Pre-decode the WAVs declared by component packs so the first
// emit doesn't pay the fetch + decode latency.
if (preloadUrls.size > 0 && soundChannel instanceof SoundChannel) {
void soundChannel.preloadSamples([...preloadUrls]);
// emit doesn't pay the fetch + decode latency. The built-in channel
// delegates to its engine; a REPLACEMENT channel (`opts.sound` as a
// Channel) has no such method, so the shared engine — when the root
// injected one — is preloaded directly. Before the extraction that
// case simply skipped the preload.
if (preloadUrls.size > 0) {
if (soundChannel instanceof SoundChannel) {
void soundChannel.preloadSamples([...preloadUrls]);
} else if (opts.soundEngine) {
void opts.soundEngine.preload([...preloadUrls]);
}
}
}

@ -38,6 +38,7 @@ const config = {
'$scene': resolve(__dirname, 'src/arts/scene'),
'$session': resolve(__dirname, 'src/arts/session'),
'$sium': resolve(__dirname, 'src/arts/sium'),
'$sound': resolve(__dirname, 'src/arts/sound'),
'$storage': resolve(__dirname, 'src/arts/storage'),
'$timer': resolve(__dirname, 'src/arts/timer'),

@ -32,6 +32,7 @@ const aliases = {
'$scene': resolve(__dirname, 'src/arts/scene'),
'$session': resolve(__dirname, 'src/arts/session'),
'$sium': resolve(__dirname, 'src/arts/sium'),
'$sound': resolve(__dirname, 'src/arts/sound'),
'$storage': resolve(__dirname, 'src/arts/storage'),
'$timer': resolve(__dirname, 'src/arts/timer'),

Loading…
Cancel
Save

Powered by TurnKey Linux.