test(sema): un botón compuesto suena DOS veces, medido (H-10)

En F0 observé que el botón de play estampa dos señales por clic. Afirmé de ahí
que sonaría dos veces, y eso era un salto: el motor tiene un árbitro de
dominancia precisamente para esos casos. Medido: **4 osciladores, dos earcons**.

Por qué el árbitro no los colapsa. `occurrenceRank = evaluable×10 + activación`:
`contact-activate` del `Button` es estructural y sin intent → **0**;
`commit-toggle-play` del player es evaluable con intent `neutral` → **10**. La
regla silencia a un recién llegado sólo si una ocurrencia YA ACTIVA lo supera, y
aquí llegan en orden 0 → 10: cuando entra el segundo, el activo no lo supera; y
el primero ya había sonado. El modelo asume que la señal de más rango llega
antes o sola — **el caso inverso, a microsegundos, no está arbitrado**. Tampoco
hay pack de `button`, así que `contact-activate` usa la base de familia (800 Hz,
60 ms) y se solapa con el commit de 100 ms que entra encima.

**No es del media-player**: es el patrón de composición del catálogo — la
primitiva acusa el contacto, el componente confirma el valor. Cualquiera que
componga `Button` y dispare su propio evento suena dos veces con el sonido
activo. El test lo pinea donde se puede oír el camino entero
(`sound-e2e.test.ts`), y la decisión —regla nueva en el árbitro, canon que
declare el solape intencionado, o packs que silencien `contact` bajo componentes
que hablan por sí mismos— queda fichada en el plan como materia de
`book-deviations.md`, no de una sesión.

Para el reproductor cambia una cosa y es la que importa para D-AP.7: el silencio
en modo audio tiene que cubrir las DOS señales, y ninguna vive en el provider.

sema + art 17 suites / 205 tests · `docs:check` 0.

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

@ -341,11 +341,14 @@ sector (`j`/`l` de YouTube, `<`/`>` de velocidad):
`[data-slider]`. Con el sonido activo, el scrubber sonoriza **por encima de
la obra**. La regla `[data-media-player][data-media='audio'] [data-slider]`
es correcta y funciona: `Element.matches()` evalúa descendencia.
3. **Y no basta con el `Slider`** (hallazgo nuevo de F0): el botón de play emite
**dos** señales por clic — `contact-activate` del `Button` compuesto **y**
`commit-toggle-play` del player, ambas estampadas en el propio `<button>`.
Una regla de silencio que seleccione el provider no alcanza a ninguna de las
dos. El silencio en audio debe cubrir **los descendientes**, no la raíz.
3. **Y no basta con el `Slider`** (hallazgo nuevo de F0, **H-10**): el botón de
play emite **dos** señales por clic — `contact-activate` del `Button`
compuesto **y** `commit-toggle-play` del player, ambas estampadas en el
propio `<button>`. Una regla de silencio que seleccione el provider no
alcanza a ninguna: el silencio en audio debe cubrir **los descendientes**.
Y no son dos estampas con un solo sonido — **medido: dos earcons**
([`sound-e2e.test.ts`](../../src/uix/sema/chans/sound-e2e.test.ts), 4
osciladores). Detalle en §8.6.
---
@ -392,6 +395,40 @@ decidir qué hacer con él: **cablearlo** (el provider ya tiene `waiting`, así
son dos líneas + el `clear` en `canplay`) o **retirarlo del morfo**. Va al gate
como parte de D-AP.10, no lo decido yo.
### 8.6 · H-10 — un botón compuesto suena DOS veces, y no es cosa del player
Empezó como una observación del stamp y acabó siendo una medición: un clic en el
botón de play produce **dos earcons**, no dos estampas con una voz.
**Por qué no lo colapsa el árbitro de dominancia.** Los rangos son
`occurrenceRank = evaluable×10 + activación`:
| Señal | Familia | Intent | Rango |
| --- | --- | --- | --- |
| `contact-activate` (del `Button`) | `contact` → estructural | ninguno | **0** |
| `commit-toggle-play` (del player) | `commit` → evaluable | `neutral` | **10** |
La regla silencia a un recién llegado **sólo si una ocurrencia ya activa lo
supera**. Aquí llegan en orden 0 → 10: cuando entra el segundo, el activo (0) no
lo supera, así que pasa; y el primero ya había sonado. El modelo asume que la
señal de más rango llega antes o sola — **el caso inverso, a microsegundos, no
está arbitrado**. Y no hay pack de `button` que module `contact-activate`: usa
la base de familia (800 Hz, 60 ms, gain 0.25), que se solapa con el commit de
100 ms que entra encima.
**No es un defecto del media-player.** Es el patrón de composición del catálogo:
la primitiva acusa el contacto y el componente confirma el valor. Cualquier
componente que componga `Button` y dispare además su propio evento suena dos
veces con el sonido activo. Por eso queda fichado aquí pero **no se decide
aquí**: o el árbitro gana una regla («una ocurrencia que supera a una activa
reclama la voz»), o el canon declara que el solape es intencionado, o los packs
silencian `contact` bajo componentes que hablan por sí mismos. Es materia de
[`book-deviations.md`](../decisions/book-deviations.md), no de este plan.
Para el reproductor sólo cambia una cosa, y es la que importa para **D-AP.7**:
el silencio en modo audio tiene que cubrir **las dos** señales, y ninguna vive
en el provider.
### vidstack#1195, caracterizado
Abierto y sin respuesta del mantenedor, que pidió reproducción. iPhone 15 /

@ -131,6 +131,39 @@ describe('audio path: EngineSemantic → SoundChannel → $sound', () => {
}
});
it('a composed button speaks TWICE per click — the primitive and the component', async () => {
// Measured in the browser on `/uix/components/media-player`: one click on
// the play button stamps `contact-activate` (the composed `Button`) and
// then `commit-toggle-play` (the player) on the SAME element. This pins
// what that means for the ear.
//
// Dominance does not collapse them: it mutes a newcomer only when an
// ALREADY-ACTIVE occurrence outranks it, and here the ranks go 0 (contact,
// structural, no intent) → 10 (commit, evaluable). The second is higher,
// so it passes; the first had already played.
const audio = createAudioSpy();
const engine = new EngineSemantic({
visual: false,
sound: { audioContextFactory: () => audio.ctx },
timers: immediateTimers
});
const target = document.createElement('button');
await engine.emit({ target, name: 'contact-activate', family: 'contact', channels: ['sound'] });
await engine.emit({
target,
name: 'commit-toggle-play',
family: 'commit',
intent: 'neutral',
channels: ['sound']
});
// Two oscillators per synthesised earcon → four means two earcons.
expect(audio.nodes.oscillators).toBe(4);
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,

Loading…
Cancel
Save

Powered by TurnKey Linux.