@driftengine/audio 3.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +11 -0
- package/dist/ambientLoop.d.ts +45 -0
- package/dist/ambientLoop.js +88 -0
- package/dist/audioHarness.d.ts +180 -0
- package/dist/audioHarness.js +244 -0
- package/dist/filters.d.ts +91 -0
- package/dist/filters.js +103 -0
- package/dist/formats.d.ts +18 -0
- package/dist/formats.js +19 -0
- package/dist/graph.d.ts +406 -0
- package/dist/graph.js +656 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +39 -0
- package/dist/manifest.d.ts +28 -0
- package/dist/manifest.js +71 -0
- package/dist/mix/bus.d.ts +203 -0
- package/dist/mix/bus.js +293 -0
- package/dist/mix/console.d.ts +96 -0
- package/dist/mix/console.js +131 -0
- package/dist/mix/defaultLayout.d.ts +37 -0
- package/dist/mix/defaultLayout.js +63 -0
- package/dist/mix/inserts.d.ts +64 -0
- package/dist/mix/inserts.js +187 -0
- package/dist/mix/returns.d.ts +38 -0
- package/dist/mix/returns.js +86 -0
- package/dist/mix/snapshot.d.ts +30 -0
- package/dist/mix/snapshot.js +55 -0
- package/dist/positional.d.ts +37 -0
- package/dist/positional.js +47 -0
- package/dist/registry.d.ts +91 -0
- package/dist/registry.js +128 -0
- package/dist/rhythm/bands.d.ts +60 -0
- package/dist/rhythm/bands.js +12 -0
- package/dist/rhythm/beatGrid.d.ts +32 -0
- package/dist/rhythm/beatGrid.js +98 -0
- package/dist/rhythm/beatMap.d.ts +42 -0
- package/dist/rhythm/beatMap.js +405 -0
- package/dist/rhythm/kickCore.d.ts +79 -0
- package/dist/rhythm/kickCore.js +166 -0
- package/dist/rhythm/kickDetector.d.ts +65 -0
- package/dist/rhythm/kickDetector.js +202 -0
- package/dist/rhythm/renderedPulse.d.ts +15 -0
- package/dist/rhythm/renderedPulse.js +138 -0
- package/dist/session.d.ts +62 -0
- package/dist/session.js +83 -0
- package/dist/spatial/ambisonic.d.ts +135 -0
- package/dist/spatial/ambisonic.js +299 -0
- package/dist/spatial/listener.d.ts +109 -0
- package/dist/spatial/listener.js +186 -0
- package/dist/spatial/occlusion.d.ts +39 -0
- package/dist/spatial/occlusion.js +92 -0
- package/dist/spatial/source.d.ts +185 -0
- package/dist/spatial/source.js +366 -0
- package/dist/spatial/zones.d.ts +129 -0
- package/dist/spatial/zones.js +166 -0
- package/dist/synth.d.ts +92 -0
- package/dist/synth.js +282 -0
- package/package.json +54 -0
- package/src/ambientLoop.ts +101 -0
- package/src/audioHarness.ts +280 -0
- package/src/filters.ts +109 -0
- package/src/formats.ts +22 -0
- package/src/graph.ts +805 -0
- package/src/index.ts +84 -0
- package/src/manifest.ts +73 -0
- package/src/mix/bus.ts +356 -0
- package/src/mix/console.ts +181 -0
- package/src/mix/defaultLayout.ts +118 -0
- package/src/mix/inserts.ts +242 -0
- package/src/mix/returns.ts +114 -0
- package/src/mix/snapshot.ts +75 -0
- package/src/positional.ts +47 -0
- package/src/registry.ts +167 -0
- package/src/rhythm/bands.ts +45 -0
- package/src/rhythm/beatGrid.ts +106 -0
- package/src/rhythm/beatMap.ts +514 -0
- package/src/rhythm/kickCore.ts +197 -0
- package/src/rhythm/kickDetector.ts +233 -0
- package/src/rhythm/renderedPulse.ts +147 -0
- package/src/session.ts +93 -0
- package/src/spatial/ambisonic.ts +358 -0
- package/src/spatial/listener.ts +249 -0
- package/src/spatial/occlusion.ts +95 -0
- package/src/spatial/source.ts +452 -0
- package/src/spatial/zones.ts +213 -0
- package/src/synth.ts +351 -0
package/dist/synth.d.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Placeholder buffer synthesis: the stand-in a sound slot uses until a real
|
|
3
|
+
* file exists for it.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately plain, and generic to any game. These exist so a build is
|
|
6
|
+
* audible and its timing can be felt while the real sounds are being made —
|
|
7
|
+
* they are not an attempt at the final thing, and treating them as one is the
|
|
8
|
+
* mistake the whole slot arrangement is designed to prevent.
|
|
9
|
+
*/
|
|
10
|
+
export declare function noiseBuffer(ctx: BaseAudioContext, seconds: number, decay: number, colour: (t: number) => number): AudioBuffer;
|
|
11
|
+
/** A pitched blip: a sine sweep, for anything that should read as a signal. */
|
|
12
|
+
export declare function toneBuffer(ctx: BaseAudioContext, seconds: number, fromHz: number, toHz: number, decay: number): AudioBuffer;
|
|
13
|
+
/**
|
|
14
|
+
* A struck metal ring: inharmonic partials over a bright transient.
|
|
15
|
+
*
|
|
16
|
+
* The one thing that separates metal from every other synthesised hit is that its
|
|
17
|
+
* partials are **not** whole multiples of the fundamental. A harmonic stack reads as a
|
|
18
|
+
* bell at best and an organ at worst; detuning the partials by irrational-ish ratios
|
|
19
|
+
* is what makes the ear hear a struck bar. The ratios below are near a free bar's own
|
|
20
|
+
* modes rather than exact, which keeps it from ringing like a tuned instrument.
|
|
21
|
+
*
|
|
22
|
+
* Each partial decays at its own rate, faster the higher it is, because that is what
|
|
23
|
+
* real metal does and it is most of why a synthesised clang usually sounds like a
|
|
24
|
+
* synthesiser: hold the top partials as long as the bottom and you get a chime.
|
|
25
|
+
*
|
|
26
|
+
* @param seconds Total length. Metal rings on; too short and it is a click.
|
|
27
|
+
* @param baseHz The fundamental the partials are built off.
|
|
28
|
+
* @param decay How fast the whole thing dies away.
|
|
29
|
+
*/
|
|
30
|
+
export declare function metalBuffer(ctx: BaseAudioContext, seconds: number, baseHz: number, decay: number): AudioBuffer;
|
|
31
|
+
/** Silence, for a slot whose only honest placeholder is nothing at all. */
|
|
32
|
+
export declare function silentBuffer(ctx: BaseAudioContext): AudioBuffer;
|
|
33
|
+
/**
|
|
34
|
+
* A continuous environmental bed, built to loop without a seam.
|
|
35
|
+
*
|
|
36
|
+
* Three things make a loop audible as a loop, and all three are handled here.
|
|
37
|
+
* The filters are warmed up before anything is recorded, so the buffer does not
|
|
38
|
+
* begin at silence and fade in. The join is crossfaded with equal-power
|
|
39
|
+
* weights, so the wrap has neither a click nor the ~3 dB hole a linear
|
|
40
|
+
* crossfade of noise leaves in the middle. And any slow swell is placed at a
|
|
41
|
+
* whole number of cycles per loop — the same reason the wind field uses integer
|
|
42
|
+
* harmonics — so it arrives back exactly where it started instead of turning
|
|
43
|
+
* the wrap into a lurch far more noticeable than the noise it rides on.
|
|
44
|
+
*/
|
|
45
|
+
export interface AmbienceOptions {
|
|
46
|
+
/** Loop length. Longer costs memory but takes longer to recognise. */
|
|
47
|
+
seconds: number;
|
|
48
|
+
/** One-pole low-pass coefficient, 0–1. Lower is darker. */
|
|
49
|
+
colour: number;
|
|
50
|
+
/**
|
|
51
|
+
* Second, slower follower subtracted from the first — a high-pass. Removes
|
|
52
|
+
* the rumble that otherwise dominates any heavily filtered noise.
|
|
53
|
+
*/
|
|
54
|
+
bodyCut?: number;
|
|
55
|
+
/** Slow level movement: how deep, 0–1, and how many whole cycles per loop. */
|
|
56
|
+
swellDepth?: number;
|
|
57
|
+
swellCycles?: number;
|
|
58
|
+
/** Sharp transients per second, and how fast each one decays. */
|
|
59
|
+
transientRate?: number;
|
|
60
|
+
transientDecay?: number;
|
|
61
|
+
/** Peak amplitude of the finished bed. */
|
|
62
|
+
gain?: number;
|
|
63
|
+
}
|
|
64
|
+
export declare function ambienceBuffer(ctx: BaseAudioContext, options: AmbienceOptions): AudioBuffer;
|
|
65
|
+
/**
|
|
66
|
+
* Fire: a broadband hiss with sparks over it.
|
|
67
|
+
*
|
|
68
|
+
* The hiss alone reads as static; the sparks are what make it fire, and they
|
|
69
|
+
* have to be irregular — anything periodic turns a hearth into a machine.
|
|
70
|
+
*/
|
|
71
|
+
export declare function fireLoopBuffer(ctx: BaseAudioContext, seconds?: number): AudioBuffer;
|
|
72
|
+
/**
|
|
73
|
+
* Water: a slow, dark wash that breathes.
|
|
74
|
+
*
|
|
75
|
+
* Nearly all the energy is low, and the swell is the part carrying the meaning
|
|
76
|
+
* — flat filtered noise at this darkness is indistinguishable from a fan.
|
|
77
|
+
*/
|
|
78
|
+
export declare function waterLoopBuffer(ctx: BaseAudioContext, seconds?: number): AudioBuffer;
|
|
79
|
+
/** Wind: mid-heavy rush, swelling harder and faster than water. */
|
|
80
|
+
export declare function windLoopBuffer(ctx: BaseAudioContext, seconds?: number): AudioBuffer;
|
|
81
|
+
/**
|
|
82
|
+
* A skate scrape: the sound of an edge losing its bite.
|
|
83
|
+
*
|
|
84
|
+
* Bright filtered noise with a slow chatter in it, so it reads as *grinding*
|
|
85
|
+
* rather than as hiss. The caller sweeps its playback rate with the drift's
|
|
86
|
+
* charge, which is what turns a texture into a meter you can hear — the pitch
|
|
87
|
+
* rising under you is the drift telling you how long you have held it.
|
|
88
|
+
*
|
|
89
|
+
* Loops without a seam: the chatter completes a whole number of cycles across
|
|
90
|
+
* the buffer, and the tail crossfades into the head.
|
|
91
|
+
*/
|
|
92
|
+
export declare function driftScrapeBuffer(ctx: BaseAudioContext, seconds?: number): AudioBuffer;
|
package/dist/synth.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Placeholder buffer synthesis: the stand-in a sound slot uses until a real
|
|
3
|
+
* file exists for it.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately plain, and generic to any game. These exist so a build is
|
|
6
|
+
* audible and its timing can be felt while the real sounds are being made —
|
|
7
|
+
* they are not an attempt at the final thing, and treating them as one is the
|
|
8
|
+
* mistake the whole slot arrangement is designed to prevent.
|
|
9
|
+
*/
|
|
10
|
+
/** A short noise burst shaped by an envelope — the workhorse placeholder. */
|
|
11
|
+
/**
|
|
12
|
+
* Makeup gain for the three-pole cascade below.
|
|
13
|
+
*
|
|
14
|
+
* **Why three poles and not one.** Every filtered-noise sound in this engine ran
|
|
15
|
+
* through a single pole, and a single pole is only 6 dB per octave — so at three or
|
|
16
|
+
* four octaves above its own cutoff it has taken off barely twenty decibels, and what
|
|
17
|
+
* survives is still broadband. Which is exactly what a listener hears: white noise
|
|
18
|
+
* everywhere, a waterfall that sounds like static. Lowering the cutoff did not fix it
|
|
19
|
+
* and could not — the leak is the slope, not the corner.
|
|
20
|
+
*
|
|
21
|
+
* Three poles in series is 18 dB per octave, which is a filter you can actually hear
|
|
22
|
+
* working. A landing becomes a thud, a slide becomes a scrape, and a waterfall becomes
|
|
23
|
+
* water rather than static with a swell on it — and every existing caller gets it
|
|
24
|
+
* without changing a single one of their numbers, because the corner frequency they
|
|
25
|
+
* each chose is unchanged. Only the slope past it moved.
|
|
26
|
+
*
|
|
27
|
+
* The cascade costs amplitude — each stage takes another bite out of the noise's own
|
|
28
|
+
* power — so this puts the level back. Measured to bring a mid-range colour term out
|
|
29
|
+
* at roughly what one pole used to.
|
|
30
|
+
*/
|
|
31
|
+
const POLE_MAKEUP = 2.6;
|
|
32
|
+
export function noiseBuffer(ctx, seconds, decay, colour) {
|
|
33
|
+
const rate = ctx.sampleRate;
|
|
34
|
+
const length = Math.max(1, Math.floor(rate * seconds));
|
|
35
|
+
const buffer = ctx.createBuffer(1, length, rate);
|
|
36
|
+
const data = buffer.getChannelData(0);
|
|
37
|
+
let p1 = 0;
|
|
38
|
+
let p2 = 0;
|
|
39
|
+
let p3 = 0;
|
|
40
|
+
for (let i = 0; i < length; i++) {
|
|
41
|
+
const t = i / length;
|
|
42
|
+
const white = Math.random() * 2 - 1;
|
|
43
|
+
const c = colour(t);
|
|
44
|
+
// Three poles, not one. See POLE_MAKEUP.
|
|
45
|
+
p1 += (white - p1) * c;
|
|
46
|
+
p2 += (p1 - p2) * c;
|
|
47
|
+
p3 += (p2 - p3) * c;
|
|
48
|
+
data[i] = p3 * POLE_MAKEUP * (1 - t) ** decay;
|
|
49
|
+
}
|
|
50
|
+
return buffer;
|
|
51
|
+
}
|
|
52
|
+
/** A pitched blip: a sine sweep, for anything that should read as a signal. */
|
|
53
|
+
export function toneBuffer(ctx, seconds, fromHz, toHz, decay) {
|
|
54
|
+
const rate = ctx.sampleRate;
|
|
55
|
+
const length = Math.max(1, Math.floor(rate * seconds));
|
|
56
|
+
const buffer = ctx.createBuffer(1, length, rate);
|
|
57
|
+
const data = buffer.getChannelData(0);
|
|
58
|
+
let phase = 0;
|
|
59
|
+
for (let i = 0; i < length; i++) {
|
|
60
|
+
const t = i / length;
|
|
61
|
+
const hz = fromHz + (toHz - fromHz) * t;
|
|
62
|
+
phase += (hz / rate) * Math.PI * 2;
|
|
63
|
+
data[i] = Math.sin(phase) * (1 - t) ** decay * 0.6;
|
|
64
|
+
}
|
|
65
|
+
return buffer;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A struck metal ring: inharmonic partials over a bright transient.
|
|
69
|
+
*
|
|
70
|
+
* The one thing that separates metal from every other synthesised hit is that its
|
|
71
|
+
* partials are **not** whole multiples of the fundamental. A harmonic stack reads as a
|
|
72
|
+
* bell at best and an organ at worst; detuning the partials by irrational-ish ratios
|
|
73
|
+
* is what makes the ear hear a struck bar. The ratios below are near a free bar's own
|
|
74
|
+
* modes rather than exact, which keeps it from ringing like a tuned instrument.
|
|
75
|
+
*
|
|
76
|
+
* Each partial decays at its own rate, faster the higher it is, because that is what
|
|
77
|
+
* real metal does and it is most of why a synthesised clang usually sounds like a
|
|
78
|
+
* synthesiser: hold the top partials as long as the bottom and you get a chime.
|
|
79
|
+
*
|
|
80
|
+
* @param seconds Total length. Metal rings on; too short and it is a click.
|
|
81
|
+
* @param baseHz The fundamental the partials are built off.
|
|
82
|
+
* @param decay How fast the whole thing dies away.
|
|
83
|
+
*/
|
|
84
|
+
export function metalBuffer(ctx, seconds, baseHz, decay) {
|
|
85
|
+
const rate = ctx.sampleRate;
|
|
86
|
+
const length = Math.max(1, Math.floor(rate * seconds));
|
|
87
|
+
const buffer = ctx.createBuffer(1, length, rate);
|
|
88
|
+
const data = buffer.getChannelData(0);
|
|
89
|
+
// Inharmonic on purpose. Whole multiples of these would be a bell.
|
|
90
|
+
const partials = [1, 2.76, 5.4, 8.93, 13.34, 18.64];
|
|
91
|
+
const phases = new Float64Array(partials.length);
|
|
92
|
+
for (let i = 0; i < length; i++) {
|
|
93
|
+
const t = i / length;
|
|
94
|
+
let sample = 0;
|
|
95
|
+
for (let p = 0; p < partials.length; p++) {
|
|
96
|
+
const ratio = partials[p] ?? 1;
|
|
97
|
+
phases[p] = (phases[p] ?? 0) + ((baseHz * ratio) / rate) * Math.PI * 2;
|
|
98
|
+
// Higher partials die first, which is the whole difference between struck
|
|
99
|
+
// metal and a chime.
|
|
100
|
+
const fade = (1 - t) ** (decay * (1 + p * 0.55));
|
|
101
|
+
sample += (Math.sin(phases[p] ?? 0) * fade) / (1 + p * 0.9);
|
|
102
|
+
}
|
|
103
|
+
// A short noise transient: the strike itself, before anything has begun to ring.
|
|
104
|
+
const strike = t < 0.012 ? (Math.random() * 2 - 1) * (1 - t / 0.012) * 0.7 : 0;
|
|
105
|
+
// Body over transient: the ring is the part that says *metal*, so it is weighted
|
|
106
|
+
// above the strike rather than under it.
|
|
107
|
+
data[i] = (sample * 0.52 + strike) * (1 - t) ** 0.6;
|
|
108
|
+
}
|
|
109
|
+
return buffer;
|
|
110
|
+
}
|
|
111
|
+
/** Silence, for a slot whose only honest placeholder is nothing at all. */
|
|
112
|
+
export function silentBuffer(ctx) {
|
|
113
|
+
return ctx.createBuffer(1, 1, ctx.sampleRate);
|
|
114
|
+
}
|
|
115
|
+
export function ambienceBuffer(ctx, options) {
|
|
116
|
+
const rate = ctx.sampleRate;
|
|
117
|
+
const length = Math.max(1, Math.floor(rate * options.seconds));
|
|
118
|
+
// Fade over an eighth of the loop: long enough to hide the join in noise,
|
|
119
|
+
// short enough that most of the buffer is still unblended material.
|
|
120
|
+
const fade = Math.max(1, Math.min(Math.floor(length / 8), Math.floor(rate * 0.35)));
|
|
121
|
+
const colour = options.colour;
|
|
122
|
+
const bodyCut = options.bodyCut ?? 0;
|
|
123
|
+
const transientRate = options.transientRate ?? 0;
|
|
124
|
+
const transientDecay = options.transientDecay ?? 60;
|
|
125
|
+
const transientChance = transientRate / rate;
|
|
126
|
+
let p1 = 0;
|
|
127
|
+
let p2 = 0;
|
|
128
|
+
let low = 0;
|
|
129
|
+
let body = 0;
|
|
130
|
+
let transient = 0;
|
|
131
|
+
const step = () => {
|
|
132
|
+
const white = Math.random() * 2 - 1;
|
|
133
|
+
// Three poles, as in `noiseBuffer` and for the same reason.
|
|
134
|
+
p1 += (white - p1) * colour;
|
|
135
|
+
p2 += (p1 - p2) * colour;
|
|
136
|
+
low += (p2 - low) * colour;
|
|
137
|
+
body += (low - body) * bodyCut;
|
|
138
|
+
// A spark is a step that decays away, not a tone.
|
|
139
|
+
if (transientChance > 0 && Math.random() < transientChance) {
|
|
140
|
+
transient = (Math.random() * 2 - 1) * 0.9;
|
|
141
|
+
}
|
|
142
|
+
transient -= transient * (transientDecay / rate);
|
|
143
|
+
return (low - body) * POLE_MAKEUP + transient;
|
|
144
|
+
};
|
|
145
|
+
/*
|
|
146
|
+
* Settle the filters first. Both start at zero, and the slow one — the
|
|
147
|
+
* high-pass follower — takes a second or more of signal to reach its working
|
|
148
|
+
* point. Recording from cold puts an audible swell at the head of the buffer
|
|
149
|
+
* that the crossfade then smears across the join.
|
|
150
|
+
*/
|
|
151
|
+
const slowest = Math.max(Math.min(bodyCut > 0 ? bodyCut : colour, colour), 1e-4);
|
|
152
|
+
const warmup = Math.min(Math.ceil(5 / slowest), rate * 3);
|
|
153
|
+
for (let i = 0; i < warmup; i++)
|
|
154
|
+
step();
|
|
155
|
+
const raw = new Float32Array(length + fade);
|
|
156
|
+
for (let i = 0; i < raw.length; i++)
|
|
157
|
+
raw[i] = step();
|
|
158
|
+
const buffer = ctx.createBuffer(1, length, rate);
|
|
159
|
+
const data = buffer.getChannelData(0);
|
|
160
|
+
const gain = options.gain ?? 1;
|
|
161
|
+
const swellDepth = options.swellDepth ?? 0;
|
|
162
|
+
const swellCycles = Math.max(0, Math.round(options.swellCycles ?? 0));
|
|
163
|
+
for (let i = 0; i < length; i++) {
|
|
164
|
+
let sample = raw[i] ?? 0;
|
|
165
|
+
if (i < fade) {
|
|
166
|
+
// Equal power, so noise keeps its level straight through the join.
|
|
167
|
+
const t = i / fade;
|
|
168
|
+
sample = sample * Math.sqrt(t) + (raw[length + i] ?? 0) * Math.sqrt(1 - t);
|
|
169
|
+
}
|
|
170
|
+
const swell = swellCycles > 0
|
|
171
|
+
? 1 -
|
|
172
|
+
swellDepth +
|
|
173
|
+
swellDepth * (0.5 + 0.5 * Math.sin((i / length) * swellCycles * Math.PI * 2))
|
|
174
|
+
: 1;
|
|
175
|
+
data[i] = sample * swell * gain;
|
|
176
|
+
}
|
|
177
|
+
return buffer;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Fire: a broadband hiss with sparks over it.
|
|
181
|
+
*
|
|
182
|
+
* The hiss alone reads as static; the sparks are what make it fire, and they
|
|
183
|
+
* have to be irregular — anything periodic turns a hearth into a machine.
|
|
184
|
+
*/
|
|
185
|
+
export function fireLoopBuffer(ctx, seconds = 4) {
|
|
186
|
+
return ambienceBuffer(ctx, {
|
|
187
|
+
seconds,
|
|
188
|
+
colour: 0.22,
|
|
189
|
+
bodyCut: 0.004,
|
|
190
|
+
swellDepth: 0.25,
|
|
191
|
+
swellCycles: 3,
|
|
192
|
+
transientRate: 26,
|
|
193
|
+
transientDecay: 900,
|
|
194
|
+
gain: 0.5,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Water: a slow, dark wash that breathes.
|
|
199
|
+
*
|
|
200
|
+
* Nearly all the energy is low, and the swell is the part carrying the meaning
|
|
201
|
+
* — flat filtered noise at this darkness is indistinguishable from a fan.
|
|
202
|
+
*/
|
|
203
|
+
export function waterLoopBuffer(ctx, seconds = 6) {
|
|
204
|
+
return ambienceBuffer(ctx, {
|
|
205
|
+
seconds,
|
|
206
|
+
colour: 0.05,
|
|
207
|
+
bodyCut: 0.0015,
|
|
208
|
+
swellDepth: 0.55,
|
|
209
|
+
swellCycles: 2,
|
|
210
|
+
gain: 0.85,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/** Wind: mid-heavy rush, swelling harder and faster than water. */
|
|
214
|
+
export function windLoopBuffer(ctx, seconds = 5) {
|
|
215
|
+
return ambienceBuffer(ctx, {
|
|
216
|
+
seconds,
|
|
217
|
+
colour: 0.1,
|
|
218
|
+
bodyCut: 0.02,
|
|
219
|
+
swellDepth: 0.7,
|
|
220
|
+
swellCycles: 3,
|
|
221
|
+
gain: 0.6,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* A skate scrape: the sound of an edge losing its bite.
|
|
226
|
+
*
|
|
227
|
+
* Bright filtered noise with a slow chatter in it, so it reads as *grinding*
|
|
228
|
+
* rather than as hiss. The caller sweeps its playback rate with the drift's
|
|
229
|
+
* charge, which is what turns a texture into a meter you can hear — the pitch
|
|
230
|
+
* rising under you is the drift telling you how long you have held it.
|
|
231
|
+
*
|
|
232
|
+
* Loops without a seam: the chatter completes a whole number of cycles across
|
|
233
|
+
* the buffer, and the tail crossfades into the head.
|
|
234
|
+
*/
|
|
235
|
+
export function driftScrapeBuffer(ctx, seconds = 1.6) {
|
|
236
|
+
const rate = ctx.sampleRate;
|
|
237
|
+
const length = Math.max(1, Math.floor(seconds * rate));
|
|
238
|
+
const buffer = ctx.createBuffer(1, length, rate);
|
|
239
|
+
const data = buffer.getChannelData(0);
|
|
240
|
+
/*
|
|
241
|
+
* Two poles of a resonant band, run over white noise.
|
|
242
|
+
*
|
|
243
|
+
* `f` sets where the band sits and is the whole difference between a scrape and a
|
|
244
|
+
* hiss. It was 0.34 — high enough that most of what survived the filter was still
|
|
245
|
+
* broadband top end, so a slide read as plain white noise. Down at 0.15 the band is
|
|
246
|
+
* in the low mids where a hard wheel on
|
|
247
|
+
* stone actually lives, and the tighter `q` narrows it further, so what comes
|
|
248
|
+
* through reads as a *material* being dragged rather than as noise being played.
|
|
249
|
+
*/
|
|
250
|
+
let low = 0;
|
|
251
|
+
let band = 0;
|
|
252
|
+
const f = 0.15;
|
|
253
|
+
const q = 0.14;
|
|
254
|
+
/** Whole cycles across the buffer, so the chatter meets itself at the loop. */
|
|
255
|
+
const chatterCycles = 11;
|
|
256
|
+
let seed = 0x2f6e2b1;
|
|
257
|
+
const noise = () => {
|
|
258
|
+
seed = (Math.imul(seed, 1664525) + 1013904223) | 0;
|
|
259
|
+
return (seed / 0x7fffffff) % 1;
|
|
260
|
+
};
|
|
261
|
+
for (let i = 0; i < length; i++) {
|
|
262
|
+
const t = i / length;
|
|
263
|
+
const chatter = 0.62 + 0.38 * Math.abs(Math.sin(Math.PI * chatterCycles * t));
|
|
264
|
+
const input = noise();
|
|
265
|
+
low += f * band;
|
|
266
|
+
band += f * (input - low - q * band);
|
|
267
|
+
// Louder per unit of noise than before, because a narrower band passes far less
|
|
268
|
+
// through: the same 0.55 on this filter is a slide nobody can hear.
|
|
269
|
+
data[i] = band * chatter * 1.5;
|
|
270
|
+
}
|
|
271
|
+
// Equal-power crossfade of the tail into the head.
|
|
272
|
+
const fade = Math.min(Math.floor(rate * 0.04), Math.floor(length / 4));
|
|
273
|
+
for (let i = 0; i < fade; i++) {
|
|
274
|
+
const t = i / fade;
|
|
275
|
+
const a = Math.cos(t * Math.PI * 0.5);
|
|
276
|
+
const b = Math.sin(t * Math.PI * 0.5);
|
|
277
|
+
const head = data[i] ?? 0;
|
|
278
|
+
const tail = data[length - fade + i] ?? 0;
|
|
279
|
+
data[i] = head * b + tail * a;
|
|
280
|
+
}
|
|
281
|
+
return buffer;
|
|
282
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@driftengine/audio",
|
|
3
|
+
"version": "3.61.0",
|
|
4
|
+
"description": "Layered stems, synthesis, rhythm analysis and positional placement",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"drift-source": "./src/index.ts",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./package.json": "./package.json",
|
|
16
|
+
"./*": "./*"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src",
|
|
21
|
+
"!src/**/*.test.ts",
|
|
22
|
+
"!src/**/*.test.mjs",
|
|
23
|
+
"!src/**/__snapshots__",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"NOTICE"
|
|
27
|
+
],
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"author": "Drift Technologies",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/drftrun/driftengine.git",
|
|
33
|
+
"directory": "packages/audio"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/drftrun/driftengine#readme",
|
|
36
|
+
"bugs": "https://github.com/drftrun/driftengine/issues",
|
|
37
|
+
"keywords": [
|
|
38
|
+
"driftengine",
|
|
39
|
+
"3d",
|
|
40
|
+
"webgl",
|
|
41
|
+
"webgpu",
|
|
42
|
+
"typescript",
|
|
43
|
+
"web-audio",
|
|
44
|
+
"spatial-audio",
|
|
45
|
+
"dsp",
|
|
46
|
+
"synthesis"
|
|
47
|
+
],
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=22.12.0"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A continuous environmental sound: a looping buffer whose level and stereo
|
|
3
|
+
* position are driven every frame by whatever the game decides is nearby.
|
|
4
|
+
*
|
|
5
|
+
* Not a full spatial audio node. A panner per source with an HRTF would be the
|
|
6
|
+
* general answer, but the sources this is for — a fire, a shoreline, wind —
|
|
7
|
+
* are diffuse and often several at once, and what actually communicates them is
|
|
8
|
+
* "how close" and "which side". Those are two numbers, and paying for a panner
|
|
9
|
+
* graph per brazier to produce them would be the expensive way to get the same
|
|
10
|
+
* result.
|
|
11
|
+
*
|
|
12
|
+
* Both setters are change-gated. They are called once per frame, and scheduling
|
|
13
|
+
* an `AudioParam` ramp sixty times a second for a value that has not moved is
|
|
14
|
+
* work the audio thread does not need.
|
|
15
|
+
*/
|
|
16
|
+
const RAMP = 0.12;
|
|
17
|
+
/** Below this the ramp is inaudible; above it, skipping the update is not. */
|
|
18
|
+
const EPSILON = 0.004;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where "now" comes from, for work this loop schedules.
|
|
22
|
+
*
|
|
23
|
+
* Supplied rather than read off the context, because a *rendered* mix has no
|
|
24
|
+
* "now": an `OfflineAudioContext` holds `currentTime` at zero for as long as the
|
|
25
|
+
* caller is describing the timeline, so a loop reading it directly would stack
|
|
26
|
+
* every level change a clip ever makes onto instant zero and play the last one
|
|
27
|
+
* for the whole file. A fire the character sprints past would sit at its parting
|
|
28
|
+
* distance from the opening frame.
|
|
29
|
+
*
|
|
30
|
+
* The graph that owns the loop already has exactly one answer to this question
|
|
31
|
+
* for exactly this reason; this is how a loop gets to share it.
|
|
32
|
+
*/
|
|
33
|
+
export type ScheduleClock = () => number;
|
|
34
|
+
|
|
35
|
+
export class AmbientLoop {
|
|
36
|
+
private lastGain = 0;
|
|
37
|
+
private lastPan = 0;
|
|
38
|
+
private stopped = false;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param scheduleAt the instant this loop's changes land on. See `ScheduleClock`.
|
|
42
|
+
* @param source the looping buffer source, already started.
|
|
43
|
+
* @param gainNode its level, initially silent.
|
|
44
|
+
* @param panNode present only where the browser supports stereo panning.
|
|
45
|
+
*/
|
|
46
|
+
constructor(
|
|
47
|
+
private readonly scheduleAt: ScheduleClock,
|
|
48
|
+
private readonly source: AudioBufferSourceNode,
|
|
49
|
+
private readonly gainNode: GainNode,
|
|
50
|
+
private readonly panNode: StereoPannerNode | null,
|
|
51
|
+
) {}
|
|
52
|
+
|
|
53
|
+
private rate = 1;
|
|
54
|
+
|
|
55
|
+
/** 0 is silent, 1 is the buffer at its authored level. */
|
|
56
|
+
setGain(gain: number): void {
|
|
57
|
+
const next = Number.isFinite(gain) ? Math.min(Math.max(gain, 0), 1) : 0;
|
|
58
|
+
if (Math.abs(next - this.lastGain) < EPSILON) return;
|
|
59
|
+
this.lastGain = next;
|
|
60
|
+
this.ramp(this.gainNode.gain, next);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Pitch the bed, for a loop whose *state* should be audible.
|
|
65
|
+
*
|
|
66
|
+
* Ramped like gain and pan rather than set, because a stepped playback rate
|
|
67
|
+
* on a continuous source is a click. Change-gated by the same rule: a rate
|
|
68
|
+
* written every frame would schedule sixty ramps a second.
|
|
69
|
+
*/
|
|
70
|
+
setRate(rate: number): void {
|
|
71
|
+
const next = Math.max(0.05, rate);
|
|
72
|
+
if (Math.abs(next - this.rate) < 0.005) return;
|
|
73
|
+
this.rate = next;
|
|
74
|
+
this.ramp(this.source.playbackRate, next);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** -1 hard left, 0 centre, 1 hard right. A no-op without stereo panning. */
|
|
78
|
+
setPan(pan: number): void {
|
|
79
|
+
if (this.panNode === null) return;
|
|
80
|
+
const next = Number.isFinite(pan) ? Math.min(Math.max(pan, -1), 1) : 0;
|
|
81
|
+
if (Math.abs(next - this.lastPan) < EPSILON) return;
|
|
82
|
+
this.lastPan = next;
|
|
83
|
+
this.ramp(this.panNode.pan, next);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
stop(): void {
|
|
87
|
+
if (this.stopped) return;
|
|
88
|
+
this.stopped = true;
|
|
89
|
+
try {
|
|
90
|
+
this.source.stop();
|
|
91
|
+
} catch {
|
|
92
|
+
// Never started, or already stopped. Nothing to undo.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private ramp(param: AudioParam, value: number): void {
|
|
97
|
+
const at = this.scheduleAt();
|
|
98
|
+
param.cancelScheduledValues(at);
|
|
99
|
+
param.setTargetAtTime(value, at, RAMP);
|
|
100
|
+
}
|
|
101
|
+
}
|