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
@@ -255,6 +255,7 @@ function throughFx(fx: FxPlan, sound: FxPlan, given: Readonly<Record<string, FxA
|
|||||||
const stages = fx.stages.map((stage) => bindStage(stage, bind));
|
const stages = fx.stages.map((stage) => bindStage(stage, bind));
|
||||||
if ('mix' in fx)
|
if ('mix' in fx)
|
||||||
return {
|
return {
|
||||||
|
...fx,
|
||||||
mix: bind(fx.mix),
|
mix: bind(fx.mix),
|
||||||
a: throughFx(fx.a, sound, given),
|
a: throughFx(fx.a, sound, given),
|
||||||
b: throughFx(fx.b, sound, given),
|
b: throughFx(fx.b, sound, given),
|
||||||
|
|||||||
@@ -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`
|
* 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
|
* 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`
|
* 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 =
|
export type FxChain =
|
||||||
| { pitch: number; shifted: boolean; stages: readonly FxStage[]; source?: FxSource }
|
| { 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: [] };
|
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`. */
|
/** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */
|
||||||
export type FxPlan =
|
export type FxPlan =
|
||||||
| { pitch: readonly FxAmount[]; stages: readonly FxStagePlan[]; source?: FxSource }
|
| { 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: [] };
|
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 = { ...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 = {
|
export const SoundFxMix: SoundFxNodeDef = {
|
||||||
type: 'SoundFxMix',
|
type: 'SoundFxMix',
|
||||||
label: 'Mix',
|
label: 'Mix',
|
||||||
category: 'sound',
|
category: 'sound',
|
||||||
description:
|
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)],
|
inputs: [{ key: 'a', label: 'a', type: 'audio' }, { key: 'b', label: 'b', type: 'audio' }, numberIn(MIX)],
|
||||||
outputs: [audioOut],
|
outputs: [audioOut],
|
||||||
params: [],
|
params: [MIX_CURVE],
|
||||||
emit: (sounds, amounts) => {
|
emit: (sounds, amounts, settings) => {
|
||||||
const mix = held(amounts.mix, MIX);
|
const mix = held(amounts.mix, MIX);
|
||||||
const a = sounds.a ?? EMPTY_PLAN;
|
const a = sounds.a ?? EMPTY_PLAN;
|
||||||
const b = sounds.b ?? EMPTY_PLAN;
|
const b = sounds.b ?? EMPTY_PLAN;
|
||||||
if (mix.param === undefined && mix.value <= 0) return a;
|
if (mix.param === undefined && mix.value <= 0) return a;
|
||||||
if (mix.param === undefined && mix.value >= 1) return b;
|
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 {
|
return {
|
||||||
mix: amountOf(plan.mix, params),
|
mix: amountOf(plan.mix, params),
|
||||||
|
...(plan.curve === 'both' && { curve: plan.curve }),
|
||||||
a: fxChainOf(plan.a, params, tempo),
|
a: fxChainOf(plan.a, params, tempo),
|
||||||
b: fxChainOf(plan.b, params, tempo),
|
b: fxChainOf(plan.b, params, tempo),
|
||||||
stages,
|
stages,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { SchedulerOptions } from '../core/music/Scheduler';
|
|||||||
import type { Sequence } from '../core/music/sequence';
|
import type { Sequence } from '../core/music/sequence';
|
||||||
import type { FxStage } from '../core/soundfx/nodes';
|
import type { FxStage } from '../core/soundfx/nodes';
|
||||||
import { Music } from '../viewport/music';
|
import { Music } from '../viewport/music';
|
||||||
import { soundLibrary, stageNodes } from '../viewport/SoundLibrary';
|
import { bothAtCenter, soundLibrary, stageNodes } from '../viewport/SoundLibrary';
|
||||||
import { curveHooks } from './debug/curveHooks';
|
import { curveHooks } from './debug/curveHooks';
|
||||||
import { documentHooks } from './debug/documentHooks';
|
import { documentHooks } from './debug/documentHooks';
|
||||||
import { graphHooks } from './debug/graphHooks';
|
import { graphHooks } from './debug/graphHooks';
|
||||||
@@ -58,6 +58,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
|||||||
stop: (file: string, letRing: boolean) => soundLibrary.stop(file, letRing),
|
stop: (file: string, letRing: boolean) => soundLibrary.stop(file, letRing),
|
||||||
/** The nodes a stage of a voice becomes, in any context – an offline one renders what it does. */
|
/** The nodes a stage of a voice becomes, in any context – an offline one renders what it does. */
|
||||||
stageNodes: (context: BaseAudioContext, stage: FxStage) => stageNodes(context, stage),
|
stageNodes: (context: BaseAudioContext, stage: FxStage) => stageNodes(context, stage),
|
||||||
|
/** What drives the two shares of a Mix "Both at center", in any context (unstarted). */
|
||||||
|
bothAtCenter: (context: BaseAudioContext, mix: number, a: GainNode, b: GainNode) =>
|
||||||
|
bothAtCenter(context, mix, a, b),
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* The scheduler (#332), which nothing in the editor plays yet: a check hands it a sequence of
|
* The scheduler (#332), which nothing in the editor plays yet: a check hands it a sequence of
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ interface Voice {
|
|||||||
/** The behavior this voice belongs to; `''` for one nothing owns (see `AudioSink.play`). */
|
/** The behavior this voice belongs to; `''` for one nothing owns (see `AudioSink.play`). */
|
||||||
owner: string;
|
owner: string;
|
||||||
sources: Source[];
|
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. */
|
/** Calls the end of one source in, once however it comes: by itself or by a release. */
|
||||||
finish: ((source: Source) => void) | null;
|
finish: ((source: Source) => void) | null;
|
||||||
gain: GainNode;
|
gain: GainNode;
|
||||||
@@ -108,6 +110,7 @@ interface Wiring {
|
|||||||
loop: boolean;
|
loop: boolean;
|
||||||
rings: boolean;
|
rings: boolean;
|
||||||
sources: Source[];
|
sources: Source[];
|
||||||
|
drivers: ConstantSourceNode[];
|
||||||
pitches: number[];
|
pitches: number[];
|
||||||
knobs: Knob[];
|
knobs: Knob[];
|
||||||
stages: number;
|
stages: number;
|
||||||
@@ -180,6 +183,8 @@ export class SoundLibrary implements AudioSink {
|
|||||||
private started = 0;
|
private started = 0;
|
||||||
private stopped = 0;
|
private stopped = 0;
|
||||||
private tuned = 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. */
|
/** The last voice actually started, with the values it was started at – what a check reads back. */
|
||||||
private last: LastVoice | null = null;
|
private last: LastVoice | null = null;
|
||||||
/**
|
/**
|
||||||
@@ -311,7 +316,7 @@ export class SoundLibrary implements AudioSink {
|
|||||||
const voice = this.voices.find((other) => other.id === id);
|
const voice = this.voices.find((other) => other.id === id);
|
||||||
if (!voice) return false;
|
if (!voice) return false;
|
||||||
voice.fx = { ...voice.fx, params: { ...voice.fx.params, ...params } };
|
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.
|
// 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));
|
if (voice.ringing) this.ringFor(voice, Math.max(0, time) + this.tailOf(voice.fx));
|
||||||
this.tuned += 1;
|
this.tuned += 1;
|
||||||
@@ -379,6 +384,7 @@ export class SoundLibrary implements AudioSink {
|
|||||||
started: number;
|
started: number;
|
||||||
stopped: number;
|
stopped: number;
|
||||||
tuned: number;
|
tuned: number;
|
||||||
|
tunedAt: number | null;
|
||||||
decoded: number;
|
decoded: number;
|
||||||
failed: number;
|
failed: number;
|
||||||
/** Whether the pitch worklet is there; null while nothing has asked for it or it is loading. */
|
/** 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,
|
started: this.started,
|
||||||
stopped: this.stopped,
|
stopped: this.stopped,
|
||||||
tuned: this.tuned,
|
tuned: this.tuned,
|
||||||
|
tunedAt: this.tunedAt,
|
||||||
decoded: this.buffers.size,
|
decoded: this.buffers.size,
|
||||||
failed: this.failed.size,
|
failed: this.failed.size,
|
||||||
worklet: this.canPitch,
|
worklet: this.canPitch,
|
||||||
@@ -514,6 +521,7 @@ export class SoundLibrary implements AudioSink {
|
|||||||
const context = this.audio();
|
const context = this.audio();
|
||||||
const done = () => {
|
const done = () => {
|
||||||
for (const source of voice.sources) source.stop();
|
for (const source of voice.sources) source.stop();
|
||||||
|
for (const driver of voice.drivers) driver.stop();
|
||||||
voice.gain.disconnect();
|
voice.gain.disconnect();
|
||||||
};
|
};
|
||||||
// A suspended clock would hold the fade – and the sound – until the next run resumes it.
|
// 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. */
|
/** A voice whose sound and echoes have run out: out of the count, and its nodes let go. */
|
||||||
private done(voice: Voice): void {
|
private done(voice: Voice): void {
|
||||||
this.forget(voice);
|
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.
|
// An echo is a loop of nodes; left connected it would keep the audio thread busy for nothing.
|
||||||
if (voice.rings) voice.gain.disconnect();
|
if (voice.rings) voice.gain.disconnect();
|
||||||
}
|
}
|
||||||
@@ -612,6 +621,7 @@ export class SoundLibrary implements AudioSink {
|
|||||||
loop,
|
loop,
|
||||||
rings,
|
rings,
|
||||||
sources: [],
|
sources: [],
|
||||||
|
drivers: [],
|
||||||
pitches: [],
|
pitches: [],
|
||||||
knobs: [],
|
knobs: [],
|
||||||
stages: 0,
|
stages: 0,
|
||||||
@@ -634,6 +644,7 @@ export class SoundLibrary implements AudioSink {
|
|||||||
file,
|
file,
|
||||||
owner,
|
owner,
|
||||||
sources,
|
sources,
|
||||||
|
drivers: wiring.drivers,
|
||||||
finish: null,
|
finish: null,
|
||||||
gain,
|
gain,
|
||||||
fx,
|
fx,
|
||||||
@@ -649,6 +660,7 @@ export class SoundLibrary implements AudioSink {
|
|||||||
for (const source of sources) source.ended(() => voice.finish?.(source));
|
for (const source of sources) source.ended(() => voice.finish?.(source));
|
||||||
this.voices.push(voice);
|
this.voices.push(voice);
|
||||||
this.started += 1;
|
this.started += 1;
|
||||||
|
for (const driver of wiring.drivers) driver.start();
|
||||||
for (const source of sources) source.start(when);
|
for (const source of sources) source.start(when);
|
||||||
if (duration > 0) this.release(id, when + duration);
|
if (duration > 0) this.release(id, when + duration);
|
||||||
}
|
}
|
||||||
@@ -663,14 +675,20 @@ export class SoundLibrary implements AudioSink {
|
|||||||
let tail: AudioNode;
|
let tail: AudioNode;
|
||||||
if ('mix' in chain) {
|
if ('mix' in chain) {
|
||||||
tail = context.createGain();
|
tail = context.createGain();
|
||||||
|
const both = chain.curve === 'both';
|
||||||
const wayA = this.wire(wiring, chain.a);
|
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);
|
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 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);
|
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);
|
} else tail = this.source(wiring, chain);
|
||||||
for (const stage of chain.stages) {
|
for (const stage of chain.stages) {
|
||||||
const made = stageNodes(context, stage);
|
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
|
* 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[] = [];
|
const levels: number[] = [];
|
||||||
for (const plan of voice.fx.layers) levelsOf(fxChainOf(plan, voice.fx.params, voice.fx.tempo), levels);
|
for (const plan of voice.fx.layers) levelsOf(fxChainOf(plan, voice.fx.params, voice.fx.tempo), levels);
|
||||||
const now = this.audio().currentTime;
|
const now = this.audio().currentTime;
|
||||||
|
let moved = false;
|
||||||
voice.knobs.forEach((knob, i) => {
|
voice.knobs.forEach((knob, i) => {
|
||||||
const level = levels[i];
|
const level = levels[i];
|
||||||
if (level === undefined || level === knob.target) return;
|
if (level === undefined || level === knob.target) return;
|
||||||
knob.target = level;
|
knob.target = level;
|
||||||
|
moved = true;
|
||||||
if (knob.param) move(knob.param, level, now, time, knob.even === 'ratio', ease);
|
if (knob.param) move(knob.param, level, now, time, knob.even === 'ratio', ease);
|
||||||
});
|
});
|
||||||
|
return moved;
|
||||||
}
|
}
|
||||||
|
|
||||||
private audio(): AudioContext {
|
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. */
|
/** Sends a level to silence in `FADE` seconds from wherever it is now. */
|
||||||
function fadeOut(param: AudioParam, now: number): void {
|
function fadeOut(param: AudioParam, now: number): void {
|
||||||
param.cancelScheduledValues(now);
|
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. */
|
/** 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[] {
|
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);
|
levelsOf(chain.a, into);
|
||||||
into.push(1 - chain.mix);
|
into.push(1 - chain.mix);
|
||||||
levelsOf(chain.b, into);
|
levelsOf(chain.b, into);
|
||||||
|
|||||||
@@ -403,6 +403,34 @@ describe('compiling a note', () => {
|
|||||||
expect(callProblem(through, lookup, f.instrument.id)).toBe('No sound FX picked');
|
expect(callProblem(through, lookup, f.instrument.id)).toBe('No sound FX picked');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the curve of a Mix inside a sound FX it sends a sound through', () => {
|
||||||
|
const f = makeInstrument();
|
||||||
|
nodeOf(f, 'InstrumentSample').params.sound = f.sound('Piano', KICK).id;
|
||||||
|
// The FX: its Input on both sides of a Mix "Both at center", then the Output.
|
||||||
|
const made = new CreateAssetCommand('soundfx', 'Wide');
|
||||||
|
f.stack.execute(made);
|
||||||
|
const fx = made.asset as SoundFxAsset;
|
||||||
|
const fxEdit = graphEditor(f, fx as unknown as BehaviorAsset);
|
||||||
|
const input = fxEdit.addNode('SoundFxInput');
|
||||||
|
const mix = fxEdit.addNode('SoundFxMix', { curve: 'both' });
|
||||||
|
const fxOut = fxEdit.addNode('SoundFxOutput');
|
||||||
|
fxEdit.wire([
|
||||||
|
[input, 'out', mix, 'a'],
|
||||||
|
[input, 'out', mix, 'b'],
|
||||||
|
[mix, 'out', fxOut, 'in'],
|
||||||
|
]);
|
||||||
|
const sample = nodeOf(f, 'InstrumentSample');
|
||||||
|
const output = nodeOf(f, 'InstrumentOutput');
|
||||||
|
f.instrument.graph.edges = f.instrument.graph.edges.filter((e) => e.to.node !== output.id);
|
||||||
|
const through = f.edit.addNode('InstrumentSoundFx', { fx: fx.id });
|
||||||
|
f.edit.wire([
|
||||||
|
[sample, 'out', through, 'in'],
|
||||||
|
[through, 'out', output, 'in'],
|
||||||
|
]);
|
||||||
|
const [layer] = compile(f, 60).layers;
|
||||||
|
expect(layer).toMatchObject({ mix: { value: 0.5 }, curve: 'both', a: { source: { file: KICK } } });
|
||||||
|
});
|
||||||
|
|
||||||
it('says what keeps it from compiling: a Sample nothing triggers, a sound that is gone', () => {
|
it('says what keeps it from compiling: a Sample nothing triggers, a sound that is gone', () => {
|
||||||
const f = makeInstrument();
|
const f = makeInstrument();
|
||||||
const sample = nodeOf(f, 'InstrumentSample');
|
const sample = nodeOf(f, 'InstrumentSample');
|
||||||
|
|||||||
@@ -230,6 +230,15 @@ describe('a Mix in a sound FX graph (#316)', () => {
|
|||||||
expect(b).toMatchObject({ pitch: 0, stages: [{ kind: 'gain' }, { kind: 'filter' }] });
|
expect(b).toMatchObject({ pitch: 0, stages: [{ kind: 'gain' }, { kind: 'filter' }] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('says its curve only where it is "Both at center", and is still one sound at 0 or 1', () => {
|
||||||
|
expect(fxChainOf(compileSoundFx(forked().fx.graph).compiled!)).not.toHaveProperty('curve');
|
||||||
|
const f = forked();
|
||||||
|
f.mix.params.curve = 'both';
|
||||||
|
expect(fxChainOf(compileSoundFx(f.fx.graph).compiled!)).toMatchObject({ mix: 0.5, curve: 'both' });
|
||||||
|
f.mix.params.mix = 0;
|
||||||
|
expect(fxChainOf(compileSoundFx(f.fx.graph).compiled!)).toMatchObject({ pitch: 12 });
|
||||||
|
});
|
||||||
|
|
||||||
it('takes its share from a parameter, held between 0 and 1, and keeps both ways for it (#315)', () => {
|
it('takes its share from a parameter, held between 0 and 1, and keeps both ways for it (#315)', () => {
|
||||||
const f = forked();
|
const f = forked();
|
||||||
f.input.ports = [{ key: 'blend', kind: 'number', default: 0.25 }];
|
f.input.ports = [{ key: 'blend', kind: 'number', default: 0.25 }];
|
||||||
|
|||||||
@@ -104,7 +104,16 @@ const disconnects: string[] = [];
|
|||||||
* The silent sources the library times things on the audio clock with (#324): when each was told to
|
* The silent sources the library times things on the audio clock with (#324): when each was told to
|
||||||
* stop, and whether it has. `clockTo` lets the clock run.
|
* stop, and whether it has. `clockTo` lets the clock run.
|
||||||
*/
|
*/
|
||||||
const clocks: { stopAt: number; fired: boolean; onended: (() => void) | null }[] = [];
|
const clocks: {
|
||||||
|
offset: FakeParam;
|
||||||
|
stopAt: number;
|
||||||
|
fired: boolean;
|
||||||
|
started: boolean;
|
||||||
|
stopped: boolean;
|
||||||
|
onended: (() => void) | null;
|
||||||
|
}[] = [];
|
||||||
|
/** The curve of every WaveShaper made, in order. */
|
||||||
|
const shapers: { curve: Float32Array | null }[] = [];
|
||||||
let contextState = 'running';
|
let contextState = 'running';
|
||||||
|
|
||||||
/** Runs the audio clock up to `until`, ending each timer on the way in order, also ones set meanwhile. */
|
/** Runs the audio clock up to `until`, ending each timer on the way in order, also ones set meanwhile. */
|
||||||
@@ -210,18 +219,28 @@ function fakeContext() {
|
|||||||
createChannelSplitter: () => wired('splitter', {}),
|
createChannelSplitter: () => wired('splitter', {}),
|
||||||
createConstantSource: () => {
|
createConstantSource: () => {
|
||||||
const clock = wired('clock', {
|
const clock = wired('clock', {
|
||||||
offset: { value: 1 },
|
offset: param(1),
|
||||||
stopAt: Infinity,
|
stopAt: Infinity,
|
||||||
fired: false,
|
fired: false,
|
||||||
|
started: false,
|
||||||
|
stopped: false,
|
||||||
onended: null as (() => void) | null,
|
onended: null as (() => void) | null,
|
||||||
start: () => {},
|
start: () => {
|
||||||
stop: (at: number) => {
|
clock.started = true;
|
||||||
clock.stopAt = at;
|
},
|
||||||
|
stop: (at?: number) => {
|
||||||
|
clock.stopAt = at ?? Infinity;
|
||||||
|
clock.stopped = true;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
clocks.push(clock);
|
clocks.push(clock);
|
||||||
return clock;
|
return clock;
|
||||||
},
|
},
|
||||||
|
createWaveShaper: () => {
|
||||||
|
const shaper = wired('shaper', { curve: null as Float32Array | null });
|
||||||
|
shapers.push(shaper);
|
||||||
|
return shaper;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +258,7 @@ beforeEach(() => {
|
|||||||
delays.length = 0;
|
delays.length = 0;
|
||||||
disconnects.length = 0;
|
disconnects.length = 0;
|
||||||
clocks.length = 0;
|
clocks.length = 0;
|
||||||
|
shapers.length = 0;
|
||||||
contextState = 'running';
|
contextState = 'running';
|
||||||
decodes = [];
|
decodes = [];
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
@@ -533,6 +553,60 @@ describe('the sound library', () => {
|
|||||||
expect(wires.filter((w) => w.startsWith('source'))).toEqual(['source > gain', 'source > gain']);
|
expect(wires.filter((w) => w.startsWith('source'))).toEqual(['source > gain', 'source > gain']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('a Mix with the curve "Both at center"', () => {
|
||||||
|
/** Two plain ways, the share open as `blend`. */
|
||||||
|
const both = (): FxVoice => ({
|
||||||
|
plan: {
|
||||||
|
mix: { value: 0.25, param: 'blend', min: 0, max: 1 },
|
||||||
|
curve: 'both',
|
||||||
|
a: { pitch: [], stages: [] },
|
||||||
|
b: { pitch: [], stages: [] },
|
||||||
|
stages: [],
|
||||||
|
},
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shapes both shares from one source carrying the mix, so a glide follows the curve', async () => {
|
||||||
|
const library = new SoundLibrary();
|
||||||
|
const voice = await play(library, FILE, {}, both());
|
||||||
|
// The voice, the Mix, share of a, share of b: the shares hold nothing of their own.
|
||||||
|
const [, , shareA, shareB] = gains;
|
||||||
|
expect([shareA!.value, shareB!.value]).toEqual([0, 0]);
|
||||||
|
expect(shapers.map((s) => [...s.curve!])).toEqual([
|
||||||
|
[1, 1, 1, 1, 0],
|
||||||
|
[0, 0, 0, 1, 1],
|
||||||
|
]);
|
||||||
|
expect(wires.filter((w) => w.startsWith('clock') || w.startsWith('shaper'))).toEqual([
|
||||||
|
'clock > shaper',
|
||||||
|
'shaper > ?',
|
||||||
|
'clock > shaper',
|
||||||
|
'shaper > ?',
|
||||||
|
]);
|
||||||
|
const [driver] = clocks;
|
||||||
|
expect(driver).toMatchObject({ started: true, stopped: false, offset: { value: 0.25 } });
|
||||||
|
expect(library.levels(FILE)).toEqual([[0.25]]);
|
||||||
|
|
||||||
|
expect(library.tune(voice, { blend: 0.75 })).toBe(true);
|
||||||
|
expect(driver!.offset.glides).toEqual([0.75]);
|
||||||
|
expect([shareA!.glides, shareB!.glides]).toEqual([[], []]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops that source with the voice, cut or ended by itself', async () => {
|
||||||
|
const library = new SoundLibrary();
|
||||||
|
await play(library, FILE, {}, both());
|
||||||
|
library.stop(FILE);
|
||||||
|
expect(clocks[0]!.stopped).toBe(false);
|
||||||
|
clockTo();
|
||||||
|
expect(clocks[0]!.stopped).toBe(true);
|
||||||
|
|
||||||
|
await play(library, FILE, {}, both());
|
||||||
|
const driver = clocks.find((c) => !c.stopped && c.started && c.offset.value === 0.25)!;
|
||||||
|
for (const source of sources.slice(-2)) source.onended?.();
|
||||||
|
expect(library.stats().playing).toBe(0);
|
||||||
|
expect(driver.stopped).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('a parameter changed while the voice plays (#315)', () => {
|
describe('a parameter changed while the voice plays (#315)', () => {
|
||||||
it('glides every knob it moves, a pitch in semitones, and leaves the rest alone', async () => {
|
it('glides every knob it moves, a pitch in semitones, and leaves the rest alone', async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary();
|
||||||
|
|||||||
+53
-4
@@ -578,8 +578,9 @@ export async function sound(t) {
|
|||||||
await play();
|
await play();
|
||||||
const pitchNow = () =>
|
const pitchNow = () =>
|
||||||
json(`({ pitch: __gm.sound.levels(${JSON.stringify(blip.file)})?.[0]?.[0], clock: __gm.sound.clock() })`);
|
json(`({ pitch: __gm.sound.levels(${JSON.stringify(blip.file)})?.[0]?.[0], clock: __gm.sound.clock() })`);
|
||||||
const sinceStart = async (seen) => {
|
const sinceStart = async (seen, since = 'start') => {
|
||||||
const from = (await stats()).last.at;
|
const now = await stats();
|
||||||
|
const from = since === 'tune' ? now.tunedAt : now.last.at;
|
||||||
return seen
|
return seen
|
||||||
.filter((s) => typeof s.pitch === 'number')
|
.filter((s) => typeof s.pitch === 'number')
|
||||||
.map((s) => ({ t: +(s.clock - from).toFixed(3), pitch: +s.pitch.toFixed(3) }));
|
.map((s) => ({ t: +(s.clock - from).toFixed(3), pitch: +s.pitch.toFixed(3) }));
|
||||||
@@ -596,12 +597,16 @@ export async function sound(t) {
|
|||||||
);
|
);
|
||||||
await stop();
|
await stop();
|
||||||
// Step holds the pitch for the whole time and then jumps there: every reading before 2 s on the
|
// Step holds the pitch for the whole time and then jumps there: every reading before 2 s on the
|
||||||
// audio clock is still 7, the first one past it is the end.
|
// audio clock is still 7, the first one past it is the end. Timed from the change, not from the
|
||||||
|
// start of the voice: task 529 saw it arrive 80 ms later and the 7 held, rightly, to 2.08 s.
|
||||||
await mutate(`
|
await mutate(`
|
||||||
const tune = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph.nodes.find((n) => n.id === 'sn-tune');
|
const tune = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph.nodes.find((n) => n.id === 'sn-tune');
|
||||||
Object.assign(tune.params, { ease: 'step', time: 2 });`);
|
Object.assign(tune.params, { ease: 'step', time: 2 });`);
|
||||||
await play();
|
await play();
|
||||||
const steps = await sinceStart(await trace(pitchNow, (s) => typeof s.pitch === 'number' && s.pitch < 6));
|
const steps = await sinceStart(
|
||||||
|
await trace(pitchNow, (s) => typeof s.pitch === 'number' && s.pitch < 6),
|
||||||
|
'tune',
|
||||||
|
);
|
||||||
const held = steps.filter(({ pitch }) => pitch === 7);
|
const held = steps.filter(({ pitch }) => pitch === 7);
|
||||||
const jumped = steps.find(({ pitch }) => pitch < 6);
|
const jumped = steps.find(({ pitch }) => pitch < 6);
|
||||||
check(
|
check(
|
||||||
@@ -750,6 +755,50 @@ export async function sound(t) {
|
|||||||
[echo.late, echo.latePlain].every((late) => late.every((n, k) => n === 0 || n === k * 128)),
|
[echo.late, echo.latePlain].every((late) => late.every((n, k) => n === 0 || n === k * 128)),
|
||||||
JSON.stringify(echo),
|
JSON.stringify(echo),
|
||||||
);
|
);
|
||||||
|
// A Mix "Both at center", rendered offline: a steady 1 on a and 0.5 on b in two channels, so each
|
||||||
|
// share reads off as it is. Then mix glides 0 → 1 over a second: at its middle both stay full.
|
||||||
|
const atCenter = await evaluate(`
|
||||||
|
(async () => {
|
||||||
|
const RATE = 48000;
|
||||||
|
const render = async (mix, glide) => {
|
||||||
|
const context = new OfflineAudioContext(2, RATE, RATE);
|
||||||
|
const merger = context.createChannelMerger(2);
|
||||||
|
merger.connect(context.destination);
|
||||||
|
const shareA = context.createGain();
|
||||||
|
const shareB = context.createGain();
|
||||||
|
shareA.gain.value = 0;
|
||||||
|
shareB.gain.value = 0;
|
||||||
|
const driver = __gm.sound.bothAtCenter(context, mix, shareA, shareB);
|
||||||
|
for (const [share, level, side] of [[shareA, 1, 0], [shareB, 0.5, 1]]) {
|
||||||
|
const steady = context.createConstantSource();
|
||||||
|
steady.offset.value = level;
|
||||||
|
steady.connect(share).connect(merger, 0, side);
|
||||||
|
steady.start();
|
||||||
|
}
|
||||||
|
if (glide) driver.offset.setValueAtTime(0, 0).linearRampToValueAtTime(1, 1);
|
||||||
|
driver.start();
|
||||||
|
const out = await context.startRendering();
|
||||||
|
const at = (s) => [out.getChannelData(0)[Math.round(s * RATE)], out.getChannelData(1)[Math.round(s * RATE)] * 2];
|
||||||
|
return glide ? [0.25, 0.5, 0.75].map(at) : at(0.5);
|
||||||
|
};
|
||||||
|
const round = (pair) => pair.map((v) => Math.round(v * 1000) / 1000);
|
||||||
|
const fixed = {};
|
||||||
|
for (const mix of [0, 0.25, 0.5, 0.75, 1]) fixed[mix] = round(await render(mix, false));
|
||||||
|
return { fixed, glide: (await render(0, true)).map(round) };
|
||||||
|
})()`);
|
||||||
|
const pairNear = (pair, a, b) => near(pair[0], a) && near(pair[1], b);
|
||||||
|
check(
|
||||||
|
'a Mix "Both at center" keeps a full up to the middle and b full from it, also while mix glides',
|
||||||
|
pairNear(atCenter.fixed[0], 1, 0) &&
|
||||||
|
pairNear(atCenter.fixed[0.25], 1, 0.5) &&
|
||||||
|
pairNear(atCenter.fixed[0.5], 1, 1) &&
|
||||||
|
pairNear(atCenter.fixed[0.75], 0.5, 1) &&
|
||||||
|
pairNear(atCenter.fixed[1], 0, 1) &&
|
||||||
|
pairNear(atCenter.glide[0], 1, 0.5) &&
|
||||||
|
pairNear(atCenter.glide[1], 1, 1) &&
|
||||||
|
pairNear(atCenter.glide[2], 0.5, 1),
|
||||||
|
JSON.stringify(atCenter),
|
||||||
|
);
|
||||||
await mutate(`
|
await mutate(`
|
||||||
const logic = __gm.state.project.assets.find((a) => a.id === 'sn-logic');
|
const logic = __gm.state.project.assets.find((a) => a.id === 'sn-logic');
|
||||||
logic.variables = [];
|
logic.variables = [];
|
||||||
|
|||||||
Reference in new issue
Block a user