Skip to content

Commit a859d85

Browse files
committed
refactor(audio): share sample fade automation
1 parent ec8fa58 commit a859d85

4 files changed

Lines changed: 175 additions & 41 deletions

File tree

src/audio/core/computeSamplePlaybackParams.js

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
MIN_DURATION_SEC,
2424
MIN_PLAYBACK_RATE,
2525
} from "../domain/constants";
26+
import { computeSampleFadeParams } from "./sampleGainAutomation";
2627

2728
export function computeSamplePlaybackParams(
2829
sampleBuffer,
@@ -77,17 +78,7 @@ export function computeSamplePlaybackParams(
7778
? Math.max(MIN_DURATION_SEC, Math.min(noteGateDuration, sourcePlayDuration))
7879
: sourcePlayDuration;
7980

80-
const fadeInSec = sourcePlayDuration * (settings.fadeInPct / 100);
81-
const shapedFadeOutPct =
82-
Math.pow(settings.fadeOutPct / 100, 0.7) * 100;
83-
const fadeOutSec = sourcePlayDuration * (shapedFadeOutPct / 100);
84-
const fadeTotal = fadeInSec + fadeOutSec;
85-
const fadeScale =
86-
fadeTotal > sourcePlayDuration * 0.98
87-
? (sourcePlayDuration * 0.98) / Math.max(0.0001, fadeTotal)
88-
: 1;
89-
const finalFadeIn = fadeInSec * fadeScale;
90-
const finalFadeOut = fadeOutSec * fadeScale;
81+
const fadeParams = computeSampleFadeParams(sourcePlayDuration, settings);
9182

9283
return {
9384
playbackRate,
@@ -99,12 +90,6 @@ export function computeSamplePlaybackParams(
9990
envReleaseSec,
10091
sourcePlayDuration,
10192
envelopeGateDuration,
102-
fadeInSec,
103-
shapedFadeOutPct,
104-
fadeOutSec,
105-
fadeTotal,
106-
fadeScale,
107-
finalFadeIn,
108-
finalFadeOut,
93+
...fadeParams,
10994
};
11095
}

src/audio/core/createSamplePlaybackNodes.js

Lines changed: 9 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { applyVolumeEnvelopeToGain } from "../domain/envelope";
2-
3-
const MIN_AUDIO_GAIN = 0.0001;
2+
import { applySampleGainAutomation } from "./sampleGainAutomation";
43

54
/**
65
* Creates the Web Audio node chain for a single sample voice, schedules the
@@ -51,30 +50,17 @@ export function createSamplePlaybackNodes(
5150
shouldApplyEnvelope,
5251
} = params;
5352

54-
const sampleStopAt = time + sourcePlayDuration;
55-
const fadeOutStart = Math.max(time, sampleStopAt - finalFadeOut);
56-
5753
source.buffer = buffer;
5854
source.playbackRate.setValueAtTime(playbackRate, time);
5955

60-
const fadeIn =
61-
options.retriggerFadeInSec > 0.001
62-
? Math.max(finalFadeIn, options.retriggerFadeInSec)
63-
: finalFadeIn;
64-
65-
if (fadeIn > 0.001) {
66-
gain.gain.setValueAtTime(MIN_AUDIO_GAIN, time);
67-
gain.gain.linearRampToValueAtTime(finalGain, time + fadeIn);
68-
} else {
69-
gain.gain.setValueAtTime(finalGain, time);
70-
}
71-
72-
gain.gain.setValueAtTime(finalGain, fadeOutStart);
73-
if (finalFadeOut > 0.001) {
74-
gain.gain.exponentialRampToValueAtTime(MIN_AUDIO_GAIN, sampleStopAt);
75-
} else {
76-
gain.gain.setValueAtTime(MIN_AUDIO_GAIN, sampleStopAt);
77-
}
56+
applySampleGainAutomation(
57+
gain.gain,
58+
time,
59+
sourcePlayDuration,
60+
finalGain,
61+
{ finalFadeIn, finalFadeOut },
62+
options,
63+
);
7864

7965
panner.pan.setValueAtTime(Math.max(-1, Math.min(1, panValue)), time);
8066

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
const MIN_AUDIO_GAIN = 0.0001;
2+
3+
/**
4+
* Computes fade durations from Sample Settings percentages for any sample-like
5+
* playback window. The same shaping is used for pattern voices and playlist
6+
* audio clips so UI In/Out controls sound consistent everywhere.
7+
*
8+
* @param {number} sourcePlayDuration
9+
* @param {object} settings
10+
* @returns {{fadeInSec:number,shapedFadeOutPct:number,fadeOutSec:number,fadeTotal:number,fadeScale:number,finalFadeIn:number,finalFadeOut:number}}
11+
*/
12+
export function computeSampleFadeParams(sourcePlayDuration, settings) {
13+
const safeDuration = Math.max(0.0001, Number(sourcePlayDuration || 0));
14+
const fadeInPct = Math.max(0, Math.min(95, Number(settings?.fadeInPct || 0)));
15+
const fadeOutPct = Math.max(0, Math.min(95, Number(settings?.fadeOutPct || 0)));
16+
const fadeInSec = safeDuration * (fadeInPct / 100);
17+
const shapedFadeOutPct = Math.pow(fadeOutPct / 100, 0.7) * 100;
18+
const fadeOutSec = safeDuration * (shapedFadeOutPct / 100);
19+
const fadeTotal = fadeInSec + fadeOutSec;
20+
const fadeScale =
21+
fadeTotal > safeDuration * 0.98
22+
? (safeDuration * 0.98) / Math.max(0.0001, fadeTotal)
23+
: 1;
24+
25+
return {
26+
fadeInSec,
27+
shapedFadeOutPct,
28+
fadeOutSec,
29+
fadeTotal,
30+
fadeScale,
31+
finalFadeIn: fadeInSec * fadeScale,
32+
finalFadeOut: fadeOutSec * fadeScale,
33+
};
34+
}
35+
36+
/**
37+
* Applies sample gain automation for fade-in and fade-out. This intentionally
38+
* mirrors the old sample-note node behavior while making it reusable by
39+
* playlist audio clips and offline render.
40+
*
41+
* @param {AudioParam} gainParam
42+
* @param {number} startTime
43+
* @param {number} sourcePlayDuration
44+
* @param {number} finalGain
45+
* @param {object} fadeParams
46+
* @param {object} [options]
47+
* @param {number} [options.retriggerFadeInSec]
48+
*/
49+
export function applySampleGainAutomation(
50+
gainParam,
51+
startTime,
52+
sourcePlayDuration,
53+
finalGain,
54+
fadeParams,
55+
options = {},
56+
) {
57+
const safeDuration = Math.max(0.0001, Number(sourcePlayDuration || 0));
58+
const stopAt = startTime + safeDuration;
59+
const finalFadeIn = Math.max(0, Number(fadeParams?.finalFadeIn || 0));
60+
const finalFadeOut = Math.max(0, Number(fadeParams?.finalFadeOut || 0));
61+
const fadeOutStart = Math.max(startTime, stopAt - finalFadeOut);
62+
const fadeIn =
63+
Number(options.retriggerFadeInSec || 0) > 0.001
64+
? Math.max(finalFadeIn, Number(options.retriggerFadeInSec || 0))
65+
: finalFadeIn;
66+
67+
if (fadeIn > 0.001) {
68+
gainParam.setValueAtTime(MIN_AUDIO_GAIN, startTime);
69+
gainParam.linearRampToValueAtTime(finalGain, startTime + fadeIn);
70+
} else {
71+
gainParam.setValueAtTime(finalGain, startTime);
72+
}
73+
74+
gainParam.setValueAtTime(finalGain, fadeOutStart);
75+
if (finalFadeOut > 0.001) {
76+
gainParam.exponentialRampToValueAtTime(MIN_AUDIO_GAIN, stopAt);
77+
} else {
78+
gainParam.setValueAtTime(MIN_AUDIO_GAIN, stopAt);
79+
}
80+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import {
3+
applySampleGainAutomation,
4+
computeSampleFadeParams,
5+
} from "./sampleGainAutomation";
6+
7+
function createGainParam() {
8+
return {
9+
setValueAtTime: vi.fn(),
10+
linearRampToValueAtTime: vi.fn(),
11+
exponentialRampToValueAtTime: vi.fn(),
12+
};
13+
}
14+
15+
describe("computeSampleFadeParams", () => {
16+
it("computes fade-in and shaped fade-out durations", () => {
17+
const result = computeSampleFadeParams(2, {
18+
fadeInPct: 10,
19+
fadeOutPct: 10,
20+
});
21+
22+
expect(result.finalFadeIn).toBeCloseTo(0.2, 5);
23+
expect(result.finalFadeOut).toBeCloseTo(result.fadeOutSec, 5);
24+
expect(result.shapedFadeOutPct).toBeGreaterThan(10);
25+
});
26+
27+
it("scales fades so they never consume the whole playback window", () => {
28+
const result = computeSampleFadeParams(1, {
29+
fadeInPct: 95,
30+
fadeOutPct: 95,
31+
});
32+
33+
expect(result.finalFadeIn + result.finalFadeOut).toBeLessThanOrEqual(0.98);
34+
expect(result.fadeScale).toBeLessThan(1);
35+
});
36+
});
37+
38+
describe("applySampleGainAutomation", () => {
39+
it("ramps from silence when fade-in is enabled", () => {
40+
const gainParam = createGainParam();
41+
42+
applySampleGainAutomation(
43+
gainParam,
44+
1,
45+
2,
46+
0.75,
47+
{ finalFadeIn: 0.25, finalFadeOut: 0 },
48+
);
49+
50+
expect(gainParam.setValueAtTime).toHaveBeenCalledWith(0.0001, 1);
51+
expect(gainParam.linearRampToValueAtTime).toHaveBeenCalledWith(0.75, 1.25);
52+
});
53+
54+
it("ramps down to silence when fade-out is enabled", () => {
55+
const gainParam = createGainParam();
56+
57+
applySampleGainAutomation(
58+
gainParam,
59+
1,
60+
2,
61+
0.75,
62+
{ finalFadeIn: 0, finalFadeOut: 0.4 },
63+
);
64+
65+
expect(gainParam.setValueAtTime).toHaveBeenCalledWith(0.75, 2.6);
66+
expect(gainParam.exponentialRampToValueAtTime).toHaveBeenCalledWith(0.0001, 3);
67+
});
68+
69+
it("uses retrigger fade-in when it is longer than the configured fade", () => {
70+
const gainParam = createGainParam();
71+
72+
applySampleGainAutomation(
73+
gainParam,
74+
1,
75+
2,
76+
0.75,
77+
{ finalFadeIn: 0.05, finalFadeOut: 0 },
78+
{ retriggerFadeInSec: 0.2 },
79+
);
80+
81+
expect(gainParam.linearRampToValueAtTime).toHaveBeenCalledWith(0.75, 1.2);
82+
});
83+
});

0 commit comments

Comments
 (0)