fix(sound): la rugosidad pasa a ser un tremolo EN SERIE — deja de sumarse a la envolvente

Conectar un nodo a un AudioParam SUMA a su automatizacion; no la escala. El
modulador de AM colgaba de `envelope.gain`, asi que su profundidad se mantenia
CONSTANTE mientras la envolvente se movia: el tremolo ni crecia con el ataque
ni moria con la caida, y alli donde la automatizacion bajaba de la profundidad
el parametro CRUZABA CERO — modulacion en anillo, no tremolo.

MEDIDO con render offline del grafo real, viejo contra nuevo:

  caso                          cuerpo RMS      cola PICO      corte
  A commit neutral (control)  0.242 -> 0.242  0.149 -> 0.149  0.0002 (igual)
  B signal neutral            0.332 -> 0.275  0.145 -> 0.088  0.0666 -> 0.0002
  C commit + risk             0.243 -> 0.200  0.263 -> 0.103  ~0
  D commit.subtle + threat    0.143 -> 0.018  0.333 -> 0.009  ~0

- B: el clic del cierre de cada aviso/error desaparece (x333 menos amplitud en
  el corte). Era el tick audible al final de TODOS los earcons de `signal`.
- C: la cola dejaba +76% de pico sobre el control; ahora muere por debajo, que
  es lo que una cola debe hacer. El sustain no se toca (ahi el tremolo ES el
  percepto de rugosidad buscado).
- D: el caso grave. `commit.subtle` (gain 0.03) cargado con `threat`
  (roughness 0.5) salia a RMS 0.143 — SIETE VECES su nivel declarado, con
  inversiones de polaridad. Ahora sale a 0.018, su nivel de diseño. Escenario
  real: un item de menu destructivo con intent="threat".
- A: el control sin AM no se mueve ni un digito. El fix no toca lo que no debe.

LA FORMA. Etapa de ganancia en serie entre el filtro y la envolvente, con
valor base `1 - depth` y el modulador sobre su propio `.gain`: el factor es
`(1 - depth) + depth*sin`, que hace pico en 1 —el tremolo nunca ANADE nivel— y
multiplica la envolvente, asi que silencio por cualquier cosa es silencio.
`depth` clampeada a 0.5 para que `1 - 2*depth >= 0`: invertir polaridad es otro
efecto. Y deja de escalarse por `gainScale`: siendo un ratio ya es relativa por
construccion, que es justo lo que el comentario viejo perseguia a mano
(«scaling both keeps the timbre and only moves the volume»).

CONTRAPARTIDA DECLARADA: los earcons con rugosidad bajan 1.6-2.7 dB de nivel
medio (B: -1.6 dB de cuerpo, -2.7 dB de pico), porque el AM ya no suma nivel.
Es correccion, no perdida — pero es un cambio perceptual real en la familia
`signal` y en todo `risk`/`threat`. Si el autor quiere recalibrar los gains
base, es una decision de catalogo aparte.

GUARD. `engine-sound.test.ts` sustituye el test que fijaba la propiedad vieja
(la profundidad escalada por gainScale) por el que fija la invariante que de
verdad importa: NADA puede conectarse al AudioParam de la envolvente, y la
etapa en serie vale `1 - depth`. Verificado en ROJO (al reconectar el modulador
a `envelope.gain` falla con su mensaje). El fallo colateral que aparecio al
hacerlo —el test de la voz desconocida avisando dos veces— era cascada: al
fallar el assert no corre `dispose()`, el AudioContext queda vivo y el
siguiente test recibe el aviso de «segundo contexto».

Doctrina: `sema.md` (sintesis) y `arts/sound/README.md` (voces) describen ahora
la etapa en serie y por que nunca se parchea un modulador sobre `envelope.gain`.

VERIFICADO: sonido + sema 244/244 · check 76 = base 75 + 1 del fichero huerfano
`__adv-verify-haptic-dead.test.ts` (sin trackear, sin borrar sin instruccion) ·
docs:check 0/616 · el dev server sirve el fix (comprobado por fetch del modulo)
· prettier: los dos ficheros eran limpios en HEAD y quedan limpios.

Cierra el hallazgo S-06 de AUDIT-sema-2026-08-05.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent 24d30ee1a8
commit 316452263f

@ -713,8 +713,15 @@ Behaviour, unchanged by the extraction:
`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; `roughness > 0.2` adds a fast AM modulator; `contour`
`roughness > 0.2` inserts a tremolo stage → ADSR-lite envelope; `contour`
(`flat` / `ascending` / `descending` / `arc` / `bell`) rides `osc.detune`.
The tremolo is a gain node IN SERIES at `1 - depth`, modulated on its own
`.gain`, so the factor peaks at 1 and the trill scales WITH the envelope.
It used to be patched onto `envelope.gain`, where a connection ADDS to the
automation instead of scaling it: the depth stayed constant while the
envelope moved, closing every `signal` note on a non-zero sample (an audible
tick) and inflating a `commit.subtle` charged with `threat` to seven times
its declared level. Pinned by `engine-sound.test.ts`.
- 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.

@ -100,8 +100,14 @@ add them with the player re-plan if its UX asks for them.
## Voices — the calibration is the consumer's
The synthesis graph (two oscillators → biquad lowpass → ADSR-lite → optional
AM) is machinery and lives here. Its perceptual CALIBRATION — the interval
The synthesis graph (two oscillators → biquad lowpass → optional tremolo →
ADSR-lite) is machinery and lives here. The tremolo sits IN SERIES before the
envelope — a gain node at `1 - depth` modulated on its own `.gain` — so the
factor peaks at 1 and the trill scales with the note. Never patch a modulator
onto `envelope.gain`: a connection to an AudioParam ADDS to its automation
instead of scaling it, which leaves the depth constant while the envelope
moves (measured 2026-08-06: an audible tick closing every `signal` earcon, and
a signature declared at gain 0.03 coming out at RMS 0.143). Its perceptual CALIBRATION — the interval
ratio and mix, the envelope clamps, the contour sweep, the roughness→AM
mapping — is a **`SoundVoice`**, registered by whoever owns the vocabulary it
was tuned against (`registerVoice(name, spec)`, the same shape as motion's

@ -249,10 +249,16 @@ describe('EngineSound', () => {
sound.dispose();
});
it('scales the AM depth with the level so an attenuated earcon is not rougher', async () => {
// The modulator rides `envelope.gain`, so leaving its depth absolute while
// the peak shrinks would change the TIMBRE of a reduced sound instead of
// just its volume.
it('wires roughness as a tremolo IN SERIES, never as an offset on the envelope gain', async () => {
// THE regression this pins. A connection to an AudioParam ADDS to its
// automation instead of scaling it, so a modulator patched onto
// `envelope.gain` kept a constant depth while the envelope moved: the
// tremolo neither grew with the attack nor died with the release, and
// wherever the automation fell below the depth the param CROSSED ZERO
// — ring modulation. Measured on the real graph (2026-08-06): `signal`
// closed every note at amplitude 0.067 (an audible tick), and a
// `commit.subtle` at gain 0.03 carrying `threat` came out at RMS 0.143,
// seven times its declared level.
const audio = createGraphHarness();
const sound = createEngineSound({
audioContextFactory: audio.factory,
@ -261,8 +267,24 @@ describe('EngineSound', () => {
await sound.play({ ...signature, roughness: 0.6 }, { gainScale: 0.5 });
// roughness (0.6) × 0.5 × gainScale (0.5).
expect(audio.constantGains()).toContain(0.15);
// The envelope is the only gain that RAMPS; everything else is constant.
const envelope = audio.gains.find((g) => g.gain.linearRampToValueAtTime.mock.calls.length > 0);
expect(envelope, 'no envelope was built').toBeDefined();
const patchedOntoEnvelopeGain = [...audio.gains, ...audio.oscillators].some((node) =>
node.connect.mock.calls.some((call) => call[0] === envelope!.gain)
);
expect(
patchedOntoEnvelopeGain,
'nothing may modulate the envelope gain param — it ADDS instead of scaling'
).toBe(false);
// depth = roughness (0.6) × depthScale (0.5) = 0.3, and the series stage
// sits at 1 - depth so the factor peaks at 1: the tremolo never ADDS
// level. The depth is NOT scaled by gainScale — as a ratio it is already
// relative, so an attenuated earcon keeps its timbre by construction.
expect(audio.constantGains()).toContain(0.3);
expect(audio.constantGains()).toContain(0.7);
sound.dispose();
});

@ -560,23 +560,48 @@ class SoundEngine implements EngineSound {
envelope.gain.linearRampToValueAtTime(peakGain, now + attackSec);
envelope.gain.setValueAtTime(peakGain, sustainEndSec);
envelope.gain.linearRampToValueAtTime(0.0001, now + durationSec);
filter.connect(envelope);
envelope.connect(out);
// Roughness → tremolo, as a stage IN SERIES BEFORE the envelope.
//
// It used to ride `envelope.gain` directly, and a connection to an
// AudioParam ADDS to its automation instead of scaling it: the depth
// stayed constant while the envelope moved, so the tremolo neither
// grew with the attack nor died with the release, and wherever the
// automation fell below the depth the gain param CROSSED ZERO — ring
// modulation, not tremolo. Measured on the real graph (2026-08-06):
// `signal` ended its note with the modulator at amplitude 0.067 (an
// audible tick closing every warning), a `commit + risk` tail carried
// +76 % peak over the un-modulated control, and a `commit.subtle` at
// gain 0.03 charged with `threat` (roughness 0.5) came out at RMS
// 0.143 — seven times its declared level, with polarity inversions.
//
// In series the factor is `(1 - depth) + depth·sin`: it peaks at 1, so
// the tremolo never ADDS level, and it multiplies the envelope, so
// silence times anything is silence. The depth is no longer scaled by
// `gainScale` — being a ratio, it is relative by construction, which is
// what the old comment wanted («keeps the timbre and only moves the
// volume») and had to hand-build.
let modulator: OscillatorNode | null = null;
if (sig.roughness > voice.am.threshold) {
// Clamped at 0.5 so `(1 - 2·depth) >= 0`: past that the factor would
// swing negative and inverting polarity is a different effect.
const depth = Math.min(0.5, sig.roughness * voice.am.depthScale);
const tremolo = ctx.createGain();
tremolo.gain.value = 1 - depth;
filter.connect(tremolo);
tremolo.connect(envelope);
modulator = ctx.createOscillator();
modulator.type = 'sine';
modulator.frequency.value =
voice.am.baseHz + (sig.roughness - voice.am.threshold) * voice.am.slopeHz;
const modulatorGain = ctx.createGain();
// Scaled too: the AM depth rides the envelope's gain param, so leaving
// it absolute would make an attenuated earcon RELATIVELY rougher than
// the same earcon at full level. Scaling both keeps the timbre and only
// moves the volume — which is what a reduction means.
modulatorGain.gain.value = sig.roughness * voice.am.depthScale * gainScale;
modulatorGain.gain.value = depth;
modulator.connect(modulatorGain);
modulatorGain.connect(envelope.gain);
modulatorGain.connect(tremolo.gain);
} else {
filter.connect(envelope);
}
this.applyContour(osc1, sig.contour, now, durationSec, voice.contour.sweepCents);

Loading…
Cancel
Save

Powered by TurnKey Linux.