From 316452263f4db25c53a12f081921c98d197e56ef Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 6 Aug 2026 00:57:04 +0200 Subject: [PATCH] =?UTF-8?q?fix(sound):=20la=20rugosidad=20pasa=20a=20ser?= =?UTF-8?q?=20un=20tremolo=20EN=20SERIE=20=E2=80=94=20deja=20de=20sumarse?= =?UTF-8?q?=20a=20la=20envolvente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/architecture/sema.md | 9 ++++++- src/arts/sound/README.md | 10 ++++++-- src/arts/sound/engine-sound.test.ts | 34 ++++++++++++++++++++----- src/arts/sound/engine-sound.ts | 39 +++++++++++++++++++++++------ 4 files changed, 76 insertions(+), 16 deletions(-) diff --git a/docs/architecture/sema.md b/docs/architecture/sema.md index 7efc7e43a..0ce66ad33 100644 --- a/docs/architecture/sema.md +++ b/docs/architecture/sema.md @@ -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. diff --git a/src/arts/sound/README.md b/src/arts/sound/README.md index 76b6243f3..6cfc809cf 100644 --- a/src/arts/sound/README.md +++ b/src/arts/sound/README.md @@ -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 diff --git a/src/arts/sound/engine-sound.test.ts b/src/arts/sound/engine-sound.test.ts index c42c9aaf9..e27c2630a 100644 --- a/src/arts/sound/engine-sound.test.ts +++ b/src/arts/sound/engine-sound.test.ts @@ -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(); }); diff --git a/src/arts/sound/engine-sound.ts b/src/arts/sound/engine-sound.ts index 6388a089f..f1d9f9274 100644 --- a/src/arts/sound/engine-sound.ts +++ b/src/arts/sound/engine-sound.ts @@ -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);