Keep both sounds full at the middle of a Mix with a curve #331
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 of23bdc3f(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:
1 parent
076c564415
commit
fb4d65a2ab
8 files changed
+261
-25
No files matched your search
@@ -70,6 +70,8 @@ interface Voice {
|
||||
/** The behavior this voice belongs to; `''` for one nothing owns (see `AudioSink.play`). */
|
||||
owner: string;
|
||||
sources: Source[];
|
||||
/** What carries a number of it on the audio thread (a Mix "Both at center"): stopped when it goes. */
|
||||
drivers: ConstantSourceNode[];
|
||||
/** Calls the end of one source in, once however it comes: by itself or by a release. */
|
||||
finish: ((source: Source) => void) | null;
|
||||
gain: GainNode;
|
||||
@@ -108,6 +110,7 @@ interface Wiring {
|
||||
loop: boolean;
|
||||
rings: boolean;
|
||||
sources: Source[];
|
||||
drivers: ConstantSourceNode[];
|
||||
pitches: number[];
|
||||
knobs: Knob[];
|
||||
stages: number;
|
||||
@@ -180,6 +183,8 @@ export class SoundLibrary implements AudioSink {
|
||||
private started = 0;
|
||||
private stopped = 0;
|
||||
private tuned = 0;
|
||||
/** Second of the audio clock the last change moved a playing voice: a timed one runs from there. */
|
||||
private tunedAt: number | null = null;
|
||||
/** The last voice actually started, with the values it was started at – what a check reads back. */
|
||||
private last: LastVoice | null = null;
|
||||
/**
|
||||
@@ -311,7 +316,7 @@ export class SoundLibrary implements AudioSink {
|
||||
const voice = this.voices.find((other) => other.id === id);
|
||||
if (!voice) return false;
|
||||
voice.fx = { ...voice.fx, params: { ...voice.fx.params, ...params } };
|
||||
this.glide(voice, time, ease);
|
||||
if (this.glide(voice, time, ease)) this.tunedAt = this.audio().currentTime;
|
||||
// A longer feedback makes for a longer tail: it is counted again from where the change ends.
|
||||
if (voice.ringing) this.ringFor(voice, Math.max(0, time) + this.tailOf(voice.fx));
|
||||
this.tuned += 1;
|
||||
@@ -379,6 +384,7 @@ export class SoundLibrary implements AudioSink {
|
||||
started: number;
|
||||
stopped: number;
|
||||
tuned: number;
|
||||
tunedAt: number | null;
|
||||
decoded: number;
|
||||
failed: number;
|
||||
/** Whether the pitch worklet is there; null while nothing has asked for it or it is loading. */
|
||||
@@ -390,6 +396,7 @@ export class SoundLibrary implements AudioSink {
|
||||
started: this.started,
|
||||
stopped: this.stopped,
|
||||
tuned: this.tuned,
|
||||
tunedAt: this.tunedAt,
|
||||
decoded: this.buffers.size,
|
||||
failed: this.failed.size,
|
||||
worklet: this.canPitch,
|
||||
@@ -514,6 +521,7 @@ export class SoundLibrary implements AudioSink {
|
||||
const context = this.audio();
|
||||
const done = () => {
|
||||
for (const source of voice.sources) source.stop();
|
||||
for (const driver of voice.drivers) driver.stop();
|
||||
voice.gain.disconnect();
|
||||
};
|
||||
// A suspended clock would hold the fade – and the sound – until the next run resumes it.
|
||||
@@ -554,6 +562,7 @@ export class SoundLibrary implements AudioSink {
|
||||
/** A voice whose sound and echoes have run out: out of the count, and its nodes let go. */
|
||||
private done(voice: Voice): void {
|
||||
this.forget(voice);
|
||||
for (const driver of voice.drivers) driver.stop();
|
||||
// An echo is a loop of nodes; left connected it would keep the audio thread busy for nothing.
|
||||
if (voice.rings) voice.gain.disconnect();
|
||||
}
|
||||
@@ -612,6 +621,7 @@ export class SoundLibrary implements AudioSink {
|
||||
loop,
|
||||
rings,
|
||||
sources: [],
|
||||
drivers: [],
|
||||
pitches: [],
|
||||
knobs: [],
|
||||
stages: 0,
|
||||
@@ -634,6 +644,7 @@ export class SoundLibrary implements AudioSink {
|
||||
file,
|
||||
owner,
|
||||
sources,
|
||||
drivers: wiring.drivers,
|
||||
finish: null,
|
||||
gain,
|
||||
fx,
|
||||
@@ -649,6 +660,7 @@ export class SoundLibrary implements AudioSink {
|
||||
for (const source of sources) source.ended(() => voice.finish?.(source));
|
||||
this.voices.push(voice);
|
||||
this.started += 1;
|
||||
for (const driver of wiring.drivers) driver.start();
|
||||
for (const source of sources) source.start(when);
|
||||
if (duration > 0) this.release(id, when + duration);
|
||||
}
|
||||
@@ -663,14 +675,20 @@ export class SoundLibrary implements AudioSink {
|
||||
let tail: AudioNode;
|
||||
if ('mix' in chain) {
|
||||
tail = context.createGain();
|
||||
const both = chain.curve === 'both';
|
||||
const wayA = this.wire(wiring, chain.a);
|
||||
const shareA = gainNode(context, 1 - chain.mix);
|
||||
const shareA = gainNode(context, both ? 0 : 1 - chain.mix);
|
||||
wayA.connect(shareA).connect(tail);
|
||||
knobs.push({ param: shareA.gain, target: 1 - chain.mix, even: 'value' });
|
||||
if (!both) knobs.push({ param: shareA.gain, target: 1 - chain.mix, even: 'value' });
|
||||
const wayB = this.wire(wiring, chain.b);
|
||||
const shareB = gainNode(context, chain.mix);
|
||||
const shareB = gainNode(context, both ? 0 : chain.mix);
|
||||
wayB.connect(shareB).connect(tail);
|
||||
knobs.push({ param: shareB.gain, target: chain.mix, even: 'value' });
|
||||
if (!both) knobs.push({ param: shareB.gain, target: chain.mix, even: 'value' });
|
||||
else {
|
||||
const driver = bothAtCenter(context, chain.mix, shareA, shareB);
|
||||
knobs.push({ param: driver.offset, target: chain.mix, even: 'value' });
|
||||
wiring.drivers.push(driver);
|
||||
}
|
||||
} else tail = this.source(wiring, chain);
|
||||
for (const stage of chain.stages) {
|
||||
const made = stageNodes(context, stage);
|
||||
@@ -772,18 +790,21 @@ export class SoundLibrary implements AudioSink {
|
||||
|
||||
/**
|
||||
* Sends every knob of a voice towards what its values say now, in `time` seconds of the audio
|
||||
* clock along `ease`, or else in a quick glide.
|
||||
* clock along `ease`, or else in a quick glide. Says whether any of them had somewhere to go.
|
||||
*/
|
||||
private glide(voice: Voice, time: number, ease: EaseShare): void {
|
||||
private glide(voice: Voice, time: number, ease: EaseShare): boolean {
|
||||
const levels: number[] = [];
|
||||
for (const plan of voice.fx.layers) levelsOf(fxChainOf(plan, voice.fx.params, voice.fx.tempo), levels);
|
||||
const now = this.audio().currentTime;
|
||||
let moved = false;
|
||||
voice.knobs.forEach((knob, i) => {
|
||||
const level = levels[i];
|
||||
if (level === undefined || level === knob.target) return;
|
||||
knob.target = level;
|
||||
moved = true;
|
||||
if (knob.param) move(knob.param, level, now, time, knob.even === 'ratio', ease);
|
||||
});
|
||||
return moved;
|
||||
}
|
||||
|
||||
private audio(): AudioContext {
|
||||
@@ -940,6 +961,36 @@ function delayNodes(context: BaseAudioContext, stage: FxDelay): StageNodes {
|
||||
};
|
||||
}
|
||||
|
||||
/** The shares of a and b over `mix` at 0, 0.5 and 1: a WaveShaper reads -1…1, so its curve starts below 0. */
|
||||
const SHARE_A = new Float32Array([1, 1, 1, 1, 0]);
|
||||
const SHARE_B = new Float32Array([0, 0, 0, 1, 1]);
|
||||
|
||||
/**
|
||||
* The shares of a Mix with the curve "Both at center": a and b each stay full up to the middle and
|
||||
* fade on the other half. A share is not a straight line of `mix` there, so two ramps of their own
|
||||
* would dip at the middle when `mix` glides across it. The source carries `mix` itself, and each
|
||||
* share is shaped from it on the audio thread. Its caller starts it and stops it with the voice.
|
||||
* Exported for a check that renders it offline in a real browser (test hook).
|
||||
*/
|
||||
export function bothAtCenter(
|
||||
context: BaseAudioContext,
|
||||
mix: number,
|
||||
a: GainNode,
|
||||
b: GainNode,
|
||||
): ConstantSourceNode {
|
||||
const driver = context.createConstantSource();
|
||||
driver.offset.value = mix;
|
||||
for (const [curve, share] of [
|
||||
[SHARE_A, a],
|
||||
[SHARE_B, b],
|
||||
] as const) {
|
||||
const shape = context.createWaveShaper();
|
||||
shape.curve = curve;
|
||||
driver.connect(shape).connect(share.gain);
|
||||
}
|
||||
return driver;
|
||||
}
|
||||
|
||||
/** Sends a level to silence in `FADE` seconds from wherever it is now. */
|
||||
function fadeOut(param: AudioParam, now: number): void {
|
||||
param.cancelScheduledValues(now);
|
||||
@@ -999,7 +1050,11 @@ function easedCurve(
|
||||
|
||||
/** The numbers of a chain in the order `wire` makes their knobs: a stage's `gain` is what its node holds. */
|
||||
function levelsOf(chain: FxChain, into: number[] = []): number[] {
|
||||
if ('mix' in chain) {
|
||||
if ('mix' in chain && chain.curve === 'both') {
|
||||
levelsOf(chain.a, into);
|
||||
levelsOf(chain.b, into);
|
||||
into.push(chain.mix);
|
||||
} else if ('mix' in chain) {
|
||||
levelsOf(chain.a, into);
|
||||
into.push(1 - chain.mix);
|
||||
levelsOf(chain.b, into);
|
||||
|
||||
Reference in new issue
Block a user