fix(sound): la invariante de un solo AudioContext, cerrada en attach y medida

`PLAN-sound-engine` daba por cableada la propiedad del motor «en los dos modos
de arranque». En attach no lo estaba: `defineUixServices` registraba `motion` y
`scene` junto a `dom` pero NO `sound`, y `defineEngineSemantic` no reenviaba
`soundEngine`. Una app en attach con sonido activo tenía el motor privado del
canal MÁS `uix.sound` — dos motores, y dos contextos en cuanto el reproductor
primase el suyo. `attachActiveUix` DOCUMENTABA el footgun en un comentario en
vez de cerrarlo, mientras `contracts.ts` ya prometía `singleContextPerDocument`.

- `defineUixServices` declara `sound` junto a `motion` / `scene`, y
  `defineEngineSemantic` lo toma como `serviceDependencies: ['dom', 'sound']` y
  lo reenvía. Degrada igual que `dom`: sin el servicio declarado llega
  `undefined` y el canal vuelve a crear el suyo (construcción directa y tests).
- La nota de `attachActiveUix` pasa de describir el footgun a describir el
  cierre, y deja escrito el único caso que sigue vivo: un esquema de servicios
  a mano que declare `events` sin `sound`.
- Fila `events` de `contracts.ts` con `serviceDependencies`, y el assert DERIVA
  de la tabla en vez de hardcodearla — que es lo que su propia doctrina pide.
- El estudio de sema (`/temas/sema`) enchufa `uix.sound`. Era la única página
  capaz de ejercitar la inyección y no lo hacía.

MEDIDO en Chrome, no deducido. Con el motor privado, cada edición del draft
reconstruye el `EngineSemantic`, el `dispose()` del canal cierra el contexto y
el siguiente disparo abre otro: 9 ediciones dejaron **10 contextos creados y 9
cerrados**, perdiendo además la caché de samples decodificados en cada una. Con
el motor compartido: **1 contexto, 0 cierres**, y 24 osciladores construidos en
6 ciclos — el earcon sigue vivo.

Dos guards, y los dos VISTOS FALLAR sin el cableado. Es la lección de la
retirada de F2, donde una medición vacua se presentó como prueba:

- `active-uix.svelte.test.ts`: el `events` de la app prima el motor de la app
  (quitando el reenvío: `expected null not to be null`).
- `sound-e2e.test.ts`: un motor inyectado sobrevive a 3 reconstrucciones con UN
  contexto y sin `close()`; sólo lo cierra quien lo creó.

Verificación: `client` 130/130 · 842/842 · `server` 262/264 · 3718/3722 — los 3
rojos son ajenos (`aura`, `menubar`, `radio-group`) y el cuarto fue un timeout
de `soma-attr-audit` por contención, verde aislado en 3,1 s · `check` en la
baseline exacta (73 errores, 0 propios).

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

@ -619,11 +619,15 @@ 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
forwards it. Both boot modes fill it without the integrator doing anything:
standalone `ActiveUix` creates the engine and passes it in (exposing it as
`uix.sound`); in attach, `defineEngineSemantic()` declares `sound` as a service
dependency and forwards whatever `defineUixServices()` registered. 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.
a consumer. That last rule is what lets a page rebuild its `EngineSemantic`
freely: the sema studio does it on every draft edit, and the context survives.
**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

@ -107,14 +107,23 @@ something imports this art.
## Composition
```ts
// active-app (attach path)
// active-app (attach path) — `defineUixServices()` already declares it
const App = createActiveApp({
services: { dom: defineActiveDom(), sound: defineEngineSound() }
services: { ...defineUixServices({ langs }), cache, session }
});
// active-uix (standalone) creates it directly and exposes uix.sound.
```
In both boot modes sema's sound channel receives this engine rather than making
its own: standalone passes `soundEngine` when it builds `EngineSemantic`, and in
attach `defineEngineSemantic()` takes `sound` as a service dependency and
forwards it. A hand-rolled service schema that declares `events` without `sound`
is the one case left where the channel opens its own context — pass
`soundEngine: App.sound` there.
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.
never call `dispose()` on it — the sema studio (`/temas/sema`) rebuilds its
`EngineSemantic` on every draft edit and the context survives all of them
because of this rule.

@ -408,6 +408,37 @@ describe('attachActiveUix (integration mode)', () => {
}
});
it('routes the app events engine through the app sound service (ONE AudioContext)', async () => {
// `contracts.ts` declares `singleContextPerDocument`. In attach that only
// holds if the app's `EngineSemantic` receives the app's sound engine:
// otherwise its channel opens a private context and `uix.sound` — the one
// a player or a waveform hangs off — opens a second.
const app = createActiveApp({
services: defineUixServices({ langs: minimalLang, events: { sound: true } })
});
const uix = attachActiveUix(app as unknown as ActiveApp);
try {
// Not a fallback: the engine active-uix exposes IS the declared service.
expect(uix.sound).toBe((app as unknown as { sound: unknown }).sound);
expect(uix.sound.context).toBe(null);
await uix.events!.emit({
target: document.createElement('button'),
name: 'commit-save',
family: 'commit',
intent: 'affirm',
channels: ['sound']
});
// The channel primed the SHARED engine — a private one would have left
// this null.
expect(uix.sound.context).not.toBe(null);
} finally {
uix.dispose();
app.dispose();
}
});
it('coexists with extra services in the same app schema', () => {
// Real-world case: app composes UIX services + its own (cache,
// session, http, ...). attachActiveUix consumes the UIX slice;

@ -206,10 +206,12 @@ export function attachActiveUix(app: ActiveApp, options: AttachActiveUixOptions
// 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.
// In attach the app also builds its own `EngineSemantic`, which would open a
// SECOND context. `defineUixServices` closes that by construction: it
// declares the `sound` service and `defineEngineSemantic` takes it as a
// dependency, forwarding it as `soundEngine`. A hand-rolled service schema
// that skips either half is the one case left where the integrator must pass
// `soundEngine: App.sound` itself.
const appSound = (app as unknown as { sound?: EngineSound }).sound;
const sound = appSound ?? createEngineSound({ dom: appDom as unknown as SoundDom });
const ownsSound = appSound === undefined;

@ -22,7 +22,8 @@ import {
defineActiveFormat,
defineActiveLangs,
defineEngineMotion,
defineEngineScene
defineEngineScene,
defineEngineSound
} from '$active-app/service-factories';
import { defineEngineSemantic } from '$uix/sema';
import type { AppServiceSchema } from '$active-app';
@ -80,6 +81,13 @@ export function defineUixServices(
// the ambient-scene engine rides the dom port; one shared engine keeps the
// concurrent-scene budget global across every canonical consumer.
services.scene = defineEngineScene();
// Sound: same shape again, and here the shared engine is not a preference
// but the contract (`contracts.ts` → `singleContextPerDocument`). Declared
// BEFORE events on purpose: `defineEngineSemantic` takes it as a service
// dependency and forwards it as `soundEngine`, so the app's sound channel
// and everything hanging off `uix.sound.context` (a player, a waveform)
// meet at ONE `AudioContext` instead of racing the browser's cap.
services.sound = defineEngineSound();
}
if (options.events !== false && options.dom !== false) {

@ -453,7 +453,8 @@ describe('UIX layer contracts', () => {
'format',
'langs',
'motion',
'scene'
'scene',
'sound'
]);
expect('semantic' in services).toBe(false);
expect('lang' in services).toBe(false);
@ -463,6 +464,7 @@ describe('UIX layer contracts', () => {
expect('dom' in noDomServices).toBe(false);
expect('events' in noDomServices).toBe(false);
expect('motion' in noDomServices).toBe(false);
expect('sound' in noDomServices).toBe(false);
const app = createActiveApp({ services: noDomServices });
try {
@ -826,7 +828,9 @@ describe('UIX layer contracts', () => {
it('events/sema uses events as service name and requires dom or projector for visual projection', () => {
const factory = defineEngineSemantic({ visual: false });
expect(factory.name).toBe(UIX_LAYER_CONTRACTS.events.publicServiceName);
expect(factory.serviceDependencies).toEqual(['dom']);
// Derived from the table, never hardcoded: `sound` is in there because the
// factory forwards it as `soundEngine` (single AudioContext in attach).
expect(factory.serviceDependencies).toEqual(UIX_LAYER_CONTRACTS.events.serviceDependencies);
expect(UIX_LAYER_CONTRACTS.events.morfoPayloadName).toBe('semantic');
expect(UIX_LAYER_CONTRACTS.events.requiresOneOfForVisualProjection).toEqual([
'dom',

@ -94,6 +94,14 @@ export interface UixLayerContractTable {
readonly publicServiceName: 'events';
readonly morfoPayloadName: 'semantic';
readonly requiresOneOfForVisualProjection: readonly ['dom', 'projector'];
/**
* What `defineEngineSemantic()` takes from the app schema. Both degrade to
* `undefined`: without `dom` there is no visual projection, and without
* `sound` the sound channel owns a private engine — which is why `sound`
* is listed here at all: it is how the row below keeps its
* `singleContextPerDocument` promise in attach mode.
*/
readonly serviceDependencies: readonly ['dom', 'sound'];
readonly createsServices: readonly [];
};
/**
@ -201,6 +209,7 @@ export const UIX_LAYER_CONTRACTS = {
publicServiceName: 'events',
morfoPayloadName: 'semantic',
requiresOneOfForVisualProjection: ['dom', 'projector'],
serviceDependencies: ['dom', 'sound'],
createsServices: []
},
sound: {

@ -2,6 +2,7 @@
import { describe, expect, it, vi } from 'vitest';
import { EngineSemantic } from '../engine';
import { createEngineSound } from '$sound';
/**
* End-to-end guard of the audio path: `EngineSemantic.emit(...)` → cascade →
@ -129,4 +130,49 @@ describe('audio path: EngineSemantic → SoundChannel → $sound', () => {
engine.dispose();
}
});
it('an injected engine survives the rebuilds: ONE context for the whole page', async () => {
// The invariant the art exists for, in the shape `/temas/sema` ships since
// it passes `soundEngine: uix.sound`: the composition root owns the engine,
// so the channel's `dispose()` on every draft edit must NOT close the
// context. Measured in Chrome before wiring it: 8 edits churned 10
// contexts; after, 1. This pins it deterministically.
const contexts: ReturnType<typeof createAudioSpy>[] = [];
const sound = createEngineSound({
audioContextFactory: () => {
const audio = createAudioSpy();
contexts.push(audio);
return audio.ctx;
},
timers: immediateTimers
});
for (let i = 0; i < 3; i++) {
const engine = new EngineSemantic({
visual: false,
sound: true,
soundEngine: sound,
timers: immediateTimers
});
await engine.emit({
target: document.createElement('button'),
name: 'commit-save',
family: 'commit',
intent: 'affirm',
channels: ['sound']
});
// Still audible on every rebuild, and always through the SAME context.
expect(contexts.length, `rebuild #${i}: contexts created`).toBe(1);
expect(contexts[0].nodes.oscillators, `rebuild #${i}`).toBeGreaterThanOrEqual(2 * (i + 1));
engine.dispose();
expect(contexts[0].ctx.close, `rebuild #${i}: close`).not.toHaveBeenCalled();
}
// Quien crea, dispone: the root closes it, and only it.
sound.dispose();
expect(contexts[0].ctx.close).toHaveBeenCalledTimes(1);
});
});

@ -23,21 +23,32 @@
import { EngineSemantic, type EngineSemanticOptions } from './engine'
import type { DomApplier } from '$adom'
import type { EngineSound } from '$sound'
import type { AppServiceFactory } from '$active-app/services'
export function defineEngineSemantic(
options: EngineSemanticOptions = {}
): AppServiceFactory<'events', readonly ['logger', 'timers'], readonly ['dom'], EngineSemantic> {
): AppServiceFactory<
'events',
readonly ['logger', 'timers'],
readonly ['dom', 'sound'],
EngineSemantic
> {
return {
name: 'events',
coreDependencies: ['logger', 'timers'],
serviceDependencies: ['dom'],
serviceDependencies: ['dom', 'sound'],
initMode: 'lazy',
create({ core, services }) {
return new EngineSemantic({
...options,
logger: options.logger ?? core.logger,
dom: options.dom ?? (services.dom as DomApplier | undefined),
// The app's shared Web Audio runtime, so the sound channel plays
// through the document's ONE `AudioContext` instead of opening a
// private one. Degrades like `dom`: an app that declares no `sound`
// service gets `undefined` and the channel owns its own engine.
soundEngine: options.soundEngine ?? (services.sound as EngineSound | undefined),
// Perceptual timers run on the app's managed clock.
timers: options.timers ?? core.timers
})

@ -41,15 +41,21 @@
}
})
// Boot uix only for its DOM + timers; the studio owns its engine so it can
// rebuild it from the draft. Chrome components emit nothing (uix sound off).
// Boot uix for its DOM + timers + the shared Web Audio runtime; the studio
// owns its semantic engine so it can rebuild it from the draft. Chrome
// components emit nothing (uix sound off).
const uix = createActiveUix({
langs: { schema: {}, defaultLocale: 'es' as const },
events: { sound: false, haptic: false }
})
setActiveUix(uix)
const engine = createStudioEngine({ dom: uix.dom, timers: uix.timers })
// `uix.sound` outlives the rebuilds: ONE `AudioContext` for the page.
const engine = createStudioEngine({
dom: uix.dom,
timers: uix.timers,
soundEngine: uix.sound
})
onDestroy(() => {
engine.dispose()
uix.dispose()

@ -4,6 +4,12 @@
* path-based retune an app ships); the draft pack enters as `components`
* (layer 5a). Firing emits through the real resolver, so what you hear IS the
* edited system — no simulation.
*
* The Web Audio runtime is NOT rebuilt with it: `soundEngine` is the shared
* `uix.sound`, so the sound channel plays through the page's single
* `AudioContext` instead of opening a private one it then closes on every
* rebuild. Measured before wiring it: 8 draft edits churned 10 contexts (and
* cleared the decoded-sample cache each time).
*/
import { EngineSemantic } from '$uix/sema'
import type {
@ -30,6 +36,7 @@ export interface StudioEngine {
export function createStudioEngine(deps: {
dom?: EngineSemanticOptions['dom']
timers?: EngineSemanticOptions['timers']
soundEngine?: EngineSemanticOptions['soundEngine']
}): StudioEngine {
const build = (runtime: Record<string, DeltaValue>, packs: readonly Sema[]) =>
new EngineSemantic({
@ -38,6 +45,8 @@ export function createStudioEngine(deps: {
haptic: true,
dom: deps.dom,
timers: deps.timers,
// Injected, therefore not owned: the channel's `dispose()` leaves it open.
soundEngine: deps.soundEngine,
components: packs,
overrides: { runtime }
})

Loading…
Cancel
Save

Powered by TurnKey Linux.