Keep both sounds full at the middle of a Mix with a curve #331
game-maker / gate (push) Successful in 3m16s
game-maker / suite (push) Successful in 16m2s

A Mix crossfaded: at 0.5 each sound was at half. Layering two sounds
in an instrument now goes through a Mix (076c564), so the user asked
for a second curve where each side stays full up to the middle and
only fades on the other half. At 0.25 a is full and b half, at 0.5
both are full, at 0.75 a is half and b full.

- The Mix has a setting Curve with Crossfade (default) and Both at
  center. A plan and a chain only say `curve` where it is `both`, so
  old projects and their compiled plans stay as they were.
- A share is not a straight line of `mix` with that curve. Two ramps
  of their own would dip to half each when `mix` glides across the
  middle. So a ConstantSourceNode carries `mix` on the audio thread,
  and two WaveShaperNodes make the shares from it. The knob of such a
  Mix is that one source.
- The source is started with the voice and stopped when it is cut or
  ends by itself, so it does not run on after the voice.
- A sound FX node in an instrument keeps the curve of a Mix inside it.
- At 0.5 both sounds are added in full, up to +6 dB where both are
  loud. That is what several edges into the Output did before. The
  wiki says so.

Found on the way: the suite of 23bdc3f (task 529) failed the Step
check in e2e 34. It timed the jump from the start of the voice, but a
timed change runs from when it arrives. In CI that was 80 ms later,
and the pitch rightly held to 2.08 s.

- stats() says `tunedAt`, the second of the audio clock the last
  change moved a voice. A change that moves nothing, as On update
  sends every frame, leaves it.
- The Step check times its readings from there.

Tests:
- sound-library.test.ts: both shares shaped from one source, a glide
  moves only the source, the source stops with a cut and with an end
  by itself (50 tests, 2 new)
- soundfx.test.ts: curve only where set, one sound at 0 (41 tests)
- instrument.test.ts: the curve through a sound FX node (34 tests)
- Counter-checks: dropping the curve in throughFx and not stopping
  the source in done() each turn their new test red
- e2e 34 on melektaus-atx: ALL PASSED, 48 checks (+1). Rendered
  offline in Chrome, a and b come to 1/0, 1/0.5, 1/1, 0.5/1 and 0/1
  at mix 0 to 1, and a glide from 0 to 1 reads the same at 0.25, 0.5
  and 0.75 s. Step jumped 2.04 s after the change.
- Gate on melektaus-atx: CHECK EXIT 0, 244 test files, 3793 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-06 13:12:56 +02:00
1 parent 076c564415
commit fb4d65a2ab
8 files changed
+261 -25

No files matched your search

+1
View File
@@ -255,6 +255,7 @@ function throughFx(fx: FxPlan, sound: FxPlan, given: Readonly<Record<string, FxA
const stages = fx.stages.map((stage) => bindStage(stage, bind));
if ('mix' in fx)
return {
...fx,
mix: bind(fx.mix),
a: throughFx(fx.a, sound, given),
b: throughFx(fx.b, sound, given),
+24 -7
View File
@@ -48,11 +48,15 @@ export interface FxSource {
* passes, in order. `gain` is a factor, a filter's `gain` is in dB as Web Audio takes it. `shifted`
* says the way has a Pitch at all – at 0 too, when that comes from a parameter – and so is played
* by the pitch worklet (#317). Behind a Mix (#316) it is two sounds, `b` making up the share `mix`
* of what goes on through `stages`.
* of what goes on through `stages` – or with the curve `both` each one full up to the middle and
* only fading on the other half.
*/
export type FxChain =
| { pitch: number; shifted: boolean; stages: readonly FxStage[]; source?: FxSource }
| { mix: number; a: FxChain; b: FxChain; stages: readonly FxStage[] };
| { mix: number; curve?: MixCurve; a: FxChain; b: FxChain; stages: readonly FxStage[] };
/** How a Mix shares its two sounds out: `crossfade` (left out of a plan) or `both` at the middle. */
export type MixCurve = 'crossfade' | 'both';
export const DRY: FxChain = { pitch: 0, shifted: false, stages: [] };
@@ -85,7 +89,7 @@ export type FxStagePlan =
/** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */
export type FxPlan =
| { pitch: readonly FxAmount[]; stages: readonly FxStagePlan[]; source?: FxSource }
| { mix: FxAmount; a: FxPlan; b: FxPlan; stages: readonly FxStagePlan[] };
| { mix: FxAmount; curve?: MixCurve; a: FxPlan; b: FxPlan; stages: readonly FxStagePlan[] };
export const EMPTY_PLAN: FxPlan = { pitch: [], stages: [] };
@@ -261,23 +265,35 @@ export const SoundFxEq: SoundFxNodeDef = {
};
const MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 0.5 };
const MIX_CURVE = {
key: 'curve',
label: 'Curve',
kind: 'enum' as const,
default: 'crossfade',
options: [
{ value: 'crossfade', label: 'Crossfade' },
{ value: 'both', label: 'Both at center' },
],
};
export const SoundFxMix: SoundFxNodeDef = {
type: 'SoundFxMix',
label: 'Mix',
category: 'sound',
description:
'Blends two sounds: at 0 only a is heard, at 1 only b, at 0.5 half of each. Both ways can start at the same Input, each with its own effects, its own Pitch included.',
'Blends two sounds: at 0 only a is heard, at 1 only b. With the curve "Crossfade" 0.5 is half of each. With "Both at center" each stays full up to the middle and only fades on the other half: 0.25 is a full and b half, 0.5 both full, added as two sounds on top of each other. Both ways can start at the same Input, each with its own effects, its own Pitch included.',
inputs: [{ key: 'a', label: 'a', type: 'audio' }, { key: 'b', label: 'b', type: 'audio' }, numberIn(MIX)],
outputs: [audioOut],
params: [],
emit: (sounds, amounts) => {
params: [MIX_CURVE],
emit: (sounds, amounts, settings) => {
const mix = held(amounts.mix, MIX);
const a = sounds.a ?? EMPTY_PLAN;
const b = sounds.b ?? EMPTY_PLAN;
if (mix.param === undefined && mix.value <= 0) return a;
if (mix.param === undefined && mix.value >= 1) return b;
return { mix, a, b, stages: [] };
return settings[MIX_CURVE.key] === 'both'
? { mix, curve: 'both', a, b, stages: [] }
: { mix, a, b, stages: [] };
},
};
@@ -397,6 +413,7 @@ export function fxChainOf(plan: FxPlan, params: FxParams = {}, tempo = DEFAULT_T
}
return {
mix: amountOf(plan.mix, params),
...(plan.curve === 'both' && { curve: plan.curve }),
a: fxChainOf(plan.a, params, tempo),
b: fxChainOf(plan.b, params, tempo),
stages,