@jgengine/shell 0.9.0 → 0.10.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/CHANGELOG.md +21 -1
- package/dist/GamePlayerShell.d.ts +6 -21
- package/dist/GamePlayerShell.js +419 -234
- package/dist/audio/audioEngine.d.ts +8 -0
- package/dist/audio/audioEngine.js +33 -0
- package/dist/audio/musicDirector.d.ts +31 -0
- package/dist/audio/musicDirector.js +125 -0
- package/dist/audio/musicVoices.d.ts +10 -0
- package/dist/audio/musicVoices.js +338 -0
- package/dist/audio/synthEngine.d.ts +9 -0
- package/dist/audio/synthEngine.js +57 -0
- package/dist/camera/GameFirstPersonCamera.d.ts +3 -0
- package/dist/camera/GameFirstPersonCamera.js +21 -4
- package/dist/camera/GameInspectionCamera.js +12 -1
- package/dist/camera/GameOrbitCamera.js +35 -1
- package/dist/camera/orbitCameraMath.d.ts +18 -0
- package/dist/camera/orbitCameraMath.js +6 -1
- package/dist/cartridge.js +1 -0
- package/dist/commandSink.d.ts +20 -0
- package/dist/commandSink.js +27 -0
- package/dist/defineGame.js +3 -1
- package/dist/devtools/CollisionDebugWorld.js +1 -0
- package/dist/devtools/collisionDebugMath.d.ts +1 -0
- package/dist/devtools/collisionDebugMath.js +2 -1
- package/dist/environment/Daylight.d.ts +7 -1
- package/dist/environment/Daylight.js +52 -5
- package/dist/environment/EnvironmentScene.js +14 -5
- package/dist/environment/RoadRibbons.d.ts +7 -0
- package/dist/environment/RoadRibbons.js +57 -0
- package/dist/inputSink.d.ts +18 -0
- package/dist/inputSink.js +35 -0
- package/dist/multiplayer.js +7 -3
- package/dist/postfx/PostProcessing.d.ts +10 -0
- package/dist/postfx/PostProcessing.js +82 -0
- package/dist/postfx/gradeShader.d.ts +4 -0
- package/dist/postfx/gradeShader.js +64 -0
- package/dist/terrain/CarvedTerrain.d.ts +4 -1
- package/dist/terrain/CarvedTerrain.js +4 -3
- package/dist/terrain/terrainDetailMaterial.d.ts +14 -0
- package/dist/terrain/terrainDetailMaterial.js +90 -0
- package/dist/touch/TouchControlsOverlay.js +11 -1
- package/dist/world/WorldHud.d.ts +3 -1
- package/dist/world/WorldHud.js +7 -7
- package/dist/world/worldBarSamples.d.ts +1 -1
- package/dist/world/worldBarSamples.js +7 -1
- package/dist/worldSync.d.ts +10 -0
- package/dist/worldSync.js +19 -0
- package/llms.txt +140 -22
- package/package.json +4 -4
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { type AudioBusDef, type SoundDef } from "@jgengine/core/audio/audioFalloff";
|
|
2
|
+
import type { MusicTheme } from "@jgengine/core/audio/music";
|
|
3
|
+
import { type CrossfadeOptions } from "./musicDirector.js";
|
|
2
4
|
export interface Vec3 {
|
|
3
5
|
x: number;
|
|
4
6
|
y: number;
|
|
@@ -7,6 +9,10 @@ export interface Vec3 {
|
|
|
7
9
|
export interface AudioSceneConfig {
|
|
8
10
|
sounds?: Record<string, SoundDef>;
|
|
9
11
|
buses?: Record<string, AudioBusDef>;
|
|
12
|
+
/** Procedural music themes, crossfaded by {@link AudioEngine.playMusic}. Mixed through the `musicBus` (default "music") so the settings volume applies. */
|
|
13
|
+
music?: Record<string, MusicTheme>;
|
|
14
|
+
/** Bus id the procedural music director mixes through. Default "music". */
|
|
15
|
+
musicBus?: string;
|
|
10
16
|
}
|
|
11
17
|
export interface AudioEmitterHandle {
|
|
12
18
|
setPosition(position: Vec3): void;
|
|
@@ -16,6 +22,8 @@ export interface AudioEngine {
|
|
|
16
22
|
setListenerPose(position: Vec3): void;
|
|
17
23
|
playOneShot(soundId: string, position?: Vec3): void;
|
|
18
24
|
playLoop(soundId: string, position?: Vec3): AudioEmitterHandle | null;
|
|
25
|
+
/** Crossfade the procedural soundtrack to `themeId` (null fades out). No-op when no `music` catalog is configured. */
|
|
26
|
+
playMusic(themeId: string | null, options?: CrossfadeOptions): void;
|
|
19
27
|
setBusGain(busId: string, gain: number): void;
|
|
20
28
|
setMasterGain(gain: number): void;
|
|
21
29
|
resume(): void;
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { distance3, resolveEmitterGain } from "@jgengine/core/audio/audioFalloff";
|
|
2
|
+
import { MusicDirector } from "./musicDirector.js";
|
|
3
|
+
import { createNoiseBuffer, realizeSynthPatch } from "./synthEngine.js";
|
|
2
4
|
function createNoopEngine() {
|
|
3
5
|
return {
|
|
4
6
|
setListenerPose: () => undefined,
|
|
5
7
|
playOneShot: () => undefined,
|
|
6
8
|
playLoop: () => null,
|
|
9
|
+
playMusic: () => undefined,
|
|
7
10
|
setBusGain: () => undefined,
|
|
8
11
|
setMasterGain: () => undefined,
|
|
9
12
|
resume: () => undefined,
|
|
@@ -43,6 +46,21 @@ export function createAudioEngine(config = {}) {
|
|
|
43
46
|
}
|
|
44
47
|
return node;
|
|
45
48
|
}
|
|
49
|
+
let noiseBuffer = null;
|
|
50
|
+
function sharedNoiseBuffer() {
|
|
51
|
+
if (noiseBuffer === null)
|
|
52
|
+
noiseBuffer = createNoiseBuffer(context);
|
|
53
|
+
return noiseBuffer;
|
|
54
|
+
}
|
|
55
|
+
let director = null;
|
|
56
|
+
function musicDirector() {
|
|
57
|
+
if (config.music === undefined)
|
|
58
|
+
return null;
|
|
59
|
+
if (director === null) {
|
|
60
|
+
director = new MusicDirector(context, busGainNode(config.musicBus ?? "music"), config.music);
|
|
61
|
+
}
|
|
62
|
+
return director;
|
|
63
|
+
}
|
|
46
64
|
const bufferCache = new Map();
|
|
47
65
|
function loadBuffer(url) {
|
|
48
66
|
let pending = bufferCache.get(url);
|
|
@@ -62,6 +80,15 @@ export function createAudioEngine(config = {}) {
|
|
|
62
80
|
return null;
|
|
63
81
|
const bus = busGainNode(sound.bus);
|
|
64
82
|
let currentPosition = position ?? listenerPosition;
|
|
83
|
+
if (sound.synth !== undefined) {
|
|
84
|
+
const cueGain = context.createGain();
|
|
85
|
+
cueGain.gain.value = resolveEmitterGain(distance3(currentPosition, listenerPosition), sound, 1);
|
|
86
|
+
cueGain.connect(bus);
|
|
87
|
+
realizeSynthPatch(context, cueGain, sharedNoiseBuffer(), sound.synth);
|
|
88
|
+
return { setPosition: () => undefined, stop: () => undefined };
|
|
89
|
+
}
|
|
90
|
+
if (sound.url === undefined)
|
|
91
|
+
return null;
|
|
65
92
|
let gainNode = null;
|
|
66
93
|
let stopped = false;
|
|
67
94
|
void loadBuffer(sound.url).then((buffer) => {
|
|
@@ -93,11 +120,16 @@ export function createAudioEngine(config = {}) {
|
|
|
93
120
|
listenerPosition = position;
|
|
94
121
|
},
|
|
95
122
|
playOneShot(soundId, position) {
|
|
123
|
+
void context.resume().catch(() => undefined);
|
|
96
124
|
playInternal(soundId, position, false);
|
|
97
125
|
},
|
|
98
126
|
playLoop(soundId, position) {
|
|
99
127
|
return playInternal(soundId, position, true);
|
|
100
128
|
},
|
|
129
|
+
playMusic(themeId, options) {
|
|
130
|
+
void context.resume().catch(() => undefined);
|
|
131
|
+
musicDirector()?.crossfadeTo(themeId, options);
|
|
132
|
+
},
|
|
101
133
|
setBusGain(busId, gain) {
|
|
102
134
|
busGainNode(busId).gain.value = gain;
|
|
103
135
|
},
|
|
@@ -108,6 +140,7 @@ export function createAudioEngine(config = {}) {
|
|
|
108
140
|
void context.resume().catch(() => undefined);
|
|
109
141
|
},
|
|
110
142
|
dispose() {
|
|
143
|
+
director?.dispose();
|
|
111
144
|
void context.close().catch(() => undefined);
|
|
112
145
|
},
|
|
113
146
|
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type MusicTheme } from "@jgengine/core/audio/music";
|
|
2
|
+
/** Options for a music crossfade. */
|
|
3
|
+
export interface CrossfadeOptions {
|
|
4
|
+
/** Semitone shift applied to every note of the incoming theme (per-zone key). Default 0. */
|
|
5
|
+
transpose?: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Runs a set of looping {@link MusicTheme}s as crossfadeable layers on one shared
|
|
9
|
+
* Web Audio graph: master → compressor → destination, with a fixed convolution
|
|
10
|
+
* reverb send. `crossfadeTo` swaps the audible theme; a 110ms lookahead scheduler
|
|
11
|
+
* keeps each active layer's notes queued ahead of the clock so loops are seamless.
|
|
12
|
+
*/
|
|
13
|
+
export declare class MusicDirector {
|
|
14
|
+
private readonly ctx;
|
|
15
|
+
private readonly master;
|
|
16
|
+
private readonly reverbSend;
|
|
17
|
+
private readonly layers;
|
|
18
|
+
private timer;
|
|
19
|
+
private volume;
|
|
20
|
+
private current;
|
|
21
|
+
constructor(ctx: BaseAudioContext, destination: AudioNode, themes: Record<string, MusicTheme>);
|
|
22
|
+
/** The currently faded-in theme id, or null when music is stopped. */
|
|
23
|
+
currentTheme(): string | null;
|
|
24
|
+
/** Fade to `themeId` (or fade all out when null). No-op if it is already current. */
|
|
25
|
+
crossfadeTo(themeId: string | null, options?: CrossfadeOptions): void;
|
|
26
|
+
/** Music volume 0..1, scaling the master gain. */
|
|
27
|
+
setVolume(volume: number): void;
|
|
28
|
+
/** Stop the scheduler and release the graph. */
|
|
29
|
+
dispose(): void;
|
|
30
|
+
private tick;
|
|
31
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { notesInWindow, themeLoopSeconds } from "@jgengine/core/audio/music";
|
|
2
|
+
import { playMusicNote } from "./musicVoices.js";
|
|
3
|
+
const LOOKAHEAD = 0.6;
|
|
4
|
+
const TICK_MS = 110;
|
|
5
|
+
const FADE_IN = 0.75;
|
|
6
|
+
const FADE_OUT = 0.35;
|
|
7
|
+
const MASTER_BASE = 0.2;
|
|
8
|
+
/**
|
|
9
|
+
* Runs a set of looping {@link MusicTheme}s as crossfadeable layers on one shared
|
|
10
|
+
* Web Audio graph: master → compressor → destination, with a fixed convolution
|
|
11
|
+
* reverb send. `crossfadeTo` swaps the audible theme; a 110ms lookahead scheduler
|
|
12
|
+
* keeps each active layer's notes queued ahead of the clock so loops are seamless.
|
|
13
|
+
*/
|
|
14
|
+
export class MusicDirector {
|
|
15
|
+
ctx;
|
|
16
|
+
master;
|
|
17
|
+
reverbSend;
|
|
18
|
+
layers = new Map();
|
|
19
|
+
timer;
|
|
20
|
+
volume = 1;
|
|
21
|
+
current = null;
|
|
22
|
+
constructor(ctx, destination, themes) {
|
|
23
|
+
this.ctx = ctx;
|
|
24
|
+
this.master = ctx.createGain();
|
|
25
|
+
this.master.gain.value = MASTER_BASE;
|
|
26
|
+
const compressor = ctx.createDynamicsCompressor();
|
|
27
|
+
compressor.threshold.value = -18;
|
|
28
|
+
compressor.knee.value = 18;
|
|
29
|
+
compressor.ratio.value = 2.2;
|
|
30
|
+
compressor.attack.value = 0.015;
|
|
31
|
+
compressor.release.value = 0.25;
|
|
32
|
+
this.master.connect(compressor);
|
|
33
|
+
compressor.connect(destination);
|
|
34
|
+
const irSeconds = 2.6;
|
|
35
|
+
const len = Math.floor(ctx.sampleRate * irSeconds);
|
|
36
|
+
const ir = ctx.createBuffer(2, len, ctx.sampleRate);
|
|
37
|
+
for (let ch = 0; ch < 2; ch += 1) {
|
|
38
|
+
const data = ir.getChannelData(ch);
|
|
39
|
+
for (let i = 0; i < len; i += 1)
|
|
40
|
+
data[i] = (Math.random() * 2 - 1) * (1 - i / len) ** 2.4;
|
|
41
|
+
}
|
|
42
|
+
const reverb = ctx.createConvolver();
|
|
43
|
+
reverb.buffer = ir;
|
|
44
|
+
this.reverbSend = ctx.createGain();
|
|
45
|
+
this.reverbSend.gain.value = 0.55;
|
|
46
|
+
this.reverbSend.connect(reverb);
|
|
47
|
+
reverb.connect(this.master);
|
|
48
|
+
for (const [id, theme] of Object.entries(themes)) {
|
|
49
|
+
const gain = ctx.createGain();
|
|
50
|
+
gain.gain.value = 0;
|
|
51
|
+
gain.connect(this.master);
|
|
52
|
+
gain.connect(this.reverbSend);
|
|
53
|
+
this.layers.set(id, {
|
|
54
|
+
theme,
|
|
55
|
+
gain,
|
|
56
|
+
trim: theme.trim ?? 1,
|
|
57
|
+
active: false,
|
|
58
|
+
target: 0,
|
|
59
|
+
anchor: 0,
|
|
60
|
+
nextFrom: 0,
|
|
61
|
+
transpose: 0,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (typeof setInterval === "function") {
|
|
65
|
+
this.timer = setInterval(() => this.tick(), TICK_MS);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** The currently faded-in theme id, or null when music is stopped. */
|
|
69
|
+
currentTheme() {
|
|
70
|
+
return this.current;
|
|
71
|
+
}
|
|
72
|
+
/** Fade to `themeId` (or fade all out when null). No-op if it is already current. */
|
|
73
|
+
crossfadeTo(themeId, options = {}) {
|
|
74
|
+
if (themeId === this.current)
|
|
75
|
+
return;
|
|
76
|
+
this.current = themeId;
|
|
77
|
+
const now = this.ctx.currentTime;
|
|
78
|
+
for (const [id, layer] of this.layers) {
|
|
79
|
+
const target = id === themeId ? layer.trim : 0;
|
|
80
|
+
if (layer.target === target)
|
|
81
|
+
continue;
|
|
82
|
+
const rising = target > layer.target;
|
|
83
|
+
layer.target = target;
|
|
84
|
+
layer.gain.gain.setTargetAtTime(target, now, rising ? FADE_IN : FADE_OUT);
|
|
85
|
+
if (rising) {
|
|
86
|
+
layer.active = true;
|
|
87
|
+
layer.anchor = now + 0.15;
|
|
88
|
+
layer.nextFrom = layer.anchor - 1e-6;
|
|
89
|
+
layer.transpose = options.transpose ?? 0;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
layer.active = false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Music volume 0..1, scaling the master gain. */
|
|
97
|
+
setVolume(volume) {
|
|
98
|
+
this.volume = Math.max(0, volume);
|
|
99
|
+
this.master.gain.value = MASTER_BASE * this.volume;
|
|
100
|
+
}
|
|
101
|
+
/** Stop the scheduler and release the graph. */
|
|
102
|
+
dispose() {
|
|
103
|
+
if (this.timer !== undefined)
|
|
104
|
+
clearInterval(this.timer);
|
|
105
|
+
this.timer = undefined;
|
|
106
|
+
this.master.disconnect();
|
|
107
|
+
this.reverbSend.disconnect();
|
|
108
|
+
}
|
|
109
|
+
tick() {
|
|
110
|
+
const horizon = this.ctx.currentTime + LOOKAHEAD;
|
|
111
|
+
const spbAvailable = 60;
|
|
112
|
+
for (const layer of this.layers.values()) {
|
|
113
|
+
if (!layer.active)
|
|
114
|
+
continue;
|
|
115
|
+
if (themeLoopSeconds(layer.theme) <= 0)
|
|
116
|
+
continue;
|
|
117
|
+
const spb = spbAvailable / layer.theme.bpm;
|
|
118
|
+
const notes = notesInWindow(layer.theme, layer.anchor, layer.nextFrom, horizon);
|
|
119
|
+
for (const { event, when } of notes) {
|
|
120
|
+
playMusicNote(this.ctx, event, when, spb, layer.gain, layer.transpose);
|
|
121
|
+
}
|
|
122
|
+
layer.nextFrom = horizon;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type NoteEvent } from "@jgengine/core/audio/music";
|
|
2
|
+
type Ctx = BaseAudioContext;
|
|
3
|
+
/**
|
|
4
|
+
* Play one theme note through its instrument voice into `out` (a per-layer gain
|
|
5
|
+
* node the director wires to master + reverb). `spb` is seconds-per-beat and
|
|
6
|
+
* `transpose` shifts the note in semitones for per-zone key changes. This is the
|
|
7
|
+
* engine's reusable instrument library; unknown instruments fall back to a sine.
|
|
8
|
+
*/
|
|
9
|
+
export declare function playMusicNote(ctx: Ctx, event: NoteEvent, when: number, spb: number, out: GainNode, transpose?: number): void;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { mtof } from "@jgengine/core/audio/music";
|
|
2
|
+
function adsr(ctx, when, dur, peak, attack, release) {
|
|
3
|
+
const g = ctx.createGain();
|
|
4
|
+
g.gain.setValueAtTime(0.0001, when);
|
|
5
|
+
g.gain.linearRampToValueAtTime(peak, when + attack);
|
|
6
|
+
g.gain.setValueAtTime(peak, Math.max(when + attack, when + dur - release));
|
|
7
|
+
g.gain.exponentialRampToValueAtTime(0.0001, when + dur + release);
|
|
8
|
+
return g;
|
|
9
|
+
}
|
|
10
|
+
function stack(ctx, when, freq, dur, out, type, dets, tail) {
|
|
11
|
+
for (const det of dets) {
|
|
12
|
+
const o = ctx.createOscillator();
|
|
13
|
+
o.type = type;
|
|
14
|
+
o.frequency.value = freq;
|
|
15
|
+
o.detune.value = det;
|
|
16
|
+
o.connect(out);
|
|
17
|
+
o.start(when);
|
|
18
|
+
o.stop(when + dur + tail);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function strings(ctx, when, freq, dur, vel, out, attack = 0.3) {
|
|
22
|
+
const g = adsr(ctx, when, dur, vel * 0.16, attack, 0.7);
|
|
23
|
+
const lp = ctx.createBiquadFilter();
|
|
24
|
+
lp.type = "lowpass";
|
|
25
|
+
lp.frequency.value = 750 + freq * 2;
|
|
26
|
+
lp.connect(g).connect(out);
|
|
27
|
+
stack(ctx, when, freq, dur, lp, "sawtooth", [-6, 5], 0.9);
|
|
28
|
+
}
|
|
29
|
+
function pad(ctx, when, freq, dur, vel, out) {
|
|
30
|
+
const g = adsr(ctx, when, dur, vel * 0.11, 0.6, 1.1);
|
|
31
|
+
const lp = ctx.createBiquadFilter();
|
|
32
|
+
lp.type = "lowpass";
|
|
33
|
+
lp.frequency.value = 520 + freq * 1.4;
|
|
34
|
+
lp.connect(g).connect(out);
|
|
35
|
+
stack(ctx, when, freq, dur, lp, "sawtooth", [-9, -3, 4, 10], 1.2);
|
|
36
|
+
}
|
|
37
|
+
function reed(ctx, when, freq, dur, vel, out) {
|
|
38
|
+
const g = adsr(ctx, when, dur, vel * 0.14, 0.08, 0.25);
|
|
39
|
+
const lp = ctx.createBiquadFilter();
|
|
40
|
+
lp.type = "lowpass";
|
|
41
|
+
lp.frequency.value = 1600 + freq;
|
|
42
|
+
lp.connect(g).connect(out);
|
|
43
|
+
stack(ctx, when, freq, dur, lp, "sawtooth", [-3, 4], 0.35);
|
|
44
|
+
}
|
|
45
|
+
function horn(ctx, when, freq, dur, vel, out) {
|
|
46
|
+
const g = adsr(ctx, when, dur, vel * 0.15, 0.09, 0.3);
|
|
47
|
+
const lp = ctx.createBiquadFilter();
|
|
48
|
+
lp.type = "lowpass";
|
|
49
|
+
lp.frequency.value = 900 + freq * 1.5;
|
|
50
|
+
lp.Q.value = 0.6;
|
|
51
|
+
lp.connect(g).connect(out);
|
|
52
|
+
stack(ctx, when, freq, dur, lp, "sawtooth", [-4, 3], 0.4);
|
|
53
|
+
const sub = ctx.createOscillator();
|
|
54
|
+
sub.type = "sine";
|
|
55
|
+
sub.frequency.value = freq;
|
|
56
|
+
const sg = ctx.createGain();
|
|
57
|
+
sg.gain.value = 0.4;
|
|
58
|
+
sub.connect(sg).connect(lp);
|
|
59
|
+
sub.start(when);
|
|
60
|
+
sub.stop(when + dur + 0.4);
|
|
61
|
+
}
|
|
62
|
+
function brassStab(ctx, when, freq, dur, vel, out) {
|
|
63
|
+
const g = adsr(ctx, when, Math.min(dur, 0.4), vel * 0.17, 0.02, 0.14);
|
|
64
|
+
const lp = ctx.createBiquadFilter();
|
|
65
|
+
lp.type = "lowpass";
|
|
66
|
+
lp.frequency.value = 1400 + freq * 2;
|
|
67
|
+
lp.connect(g).connect(out);
|
|
68
|
+
stack(ctx, when, freq, Math.min(dur, 0.4), lp, "sawtooth", [-5, 6], 0.2);
|
|
69
|
+
}
|
|
70
|
+
function flute(ctx, when, freq, dur, vel, out) {
|
|
71
|
+
const g = adsr(ctx, when, dur, vel * 0.15, 0.06, 0.2);
|
|
72
|
+
const lp = ctx.createBiquadFilter();
|
|
73
|
+
lp.type = "lowpass";
|
|
74
|
+
lp.frequency.value = 2600;
|
|
75
|
+
lp.connect(g).connect(out);
|
|
76
|
+
const o = ctx.createOscillator();
|
|
77
|
+
o.type = "sine";
|
|
78
|
+
o.frequency.value = freq;
|
|
79
|
+
o.connect(lp);
|
|
80
|
+
o.start(when);
|
|
81
|
+
o.stop(when + dur + 0.25);
|
|
82
|
+
const breath = ctx.createBufferSource();
|
|
83
|
+
const len = Math.floor(ctx.sampleRate * Math.min(dur, 0.4));
|
|
84
|
+
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
|
|
85
|
+
const data = buf.getChannelData(0);
|
|
86
|
+
for (let i = 0; i < len; i += 1)
|
|
87
|
+
data[i] = (Math.random() * 2 - 1) * 0.5;
|
|
88
|
+
breath.buffer = buf;
|
|
89
|
+
const bp = ctx.createBiquadFilter();
|
|
90
|
+
bp.type = "bandpass";
|
|
91
|
+
bp.frequency.value = freq * 2;
|
|
92
|
+
const bg = ctx.createGain();
|
|
93
|
+
bg.gain.value = vel * 0.02;
|
|
94
|
+
breath.connect(bp).connect(bg).connect(out);
|
|
95
|
+
breath.start(when);
|
|
96
|
+
}
|
|
97
|
+
function pipe(ctx, when, freq, dur, vel, out) {
|
|
98
|
+
const g = adsr(ctx, when, dur, vel * 0.13, 0.03, 0.15);
|
|
99
|
+
const lp = ctx.createBiquadFilter();
|
|
100
|
+
lp.type = "lowpass";
|
|
101
|
+
lp.frequency.value = 3000;
|
|
102
|
+
lp.connect(g).connect(out);
|
|
103
|
+
stack(ctx, when, freq, dur, lp, "triangle", [0, 7], 0.2);
|
|
104
|
+
}
|
|
105
|
+
function squareLead(ctx, when, freq, dur, vel, out) {
|
|
106
|
+
const g = adsr(ctx, when, dur, vel * 0.1, 0.02, 0.12);
|
|
107
|
+
const lp = ctx.createBiquadFilter();
|
|
108
|
+
lp.type = "lowpass";
|
|
109
|
+
lp.frequency.value = 2200 + freq;
|
|
110
|
+
lp.connect(g).connect(out);
|
|
111
|
+
const o = ctx.createOscillator();
|
|
112
|
+
o.type = "square";
|
|
113
|
+
o.frequency.value = freq;
|
|
114
|
+
o.connect(lp);
|
|
115
|
+
o.start(when);
|
|
116
|
+
o.stop(when + dur + 0.2);
|
|
117
|
+
}
|
|
118
|
+
function choir(ctx, when, freq, dur, vel, out) {
|
|
119
|
+
const g = adsr(ctx, when, dur, vel * 0.12, 0.25, 0.6);
|
|
120
|
+
const formant = ctx.createBiquadFilter();
|
|
121
|
+
formant.type = "bandpass";
|
|
122
|
+
formant.frequency.value = Math.min(1800, 500 + freq * 1.6);
|
|
123
|
+
formant.Q.value = 0.7;
|
|
124
|
+
formant.connect(g).connect(out);
|
|
125
|
+
stack(ctx, when, freq, dur, formant, "sawtooth", [-8, -2, 6], 0.8);
|
|
126
|
+
stack(ctx, when, freq * 2, dur, formant, "triangle", [3], 0.8);
|
|
127
|
+
}
|
|
128
|
+
function oboe(ctx, when, freq, dur, vel, out) {
|
|
129
|
+
const g = adsr(ctx, when, dur, vel * 0.17, 0.055, 0.22);
|
|
130
|
+
const formant = ctx.createBiquadFilter();
|
|
131
|
+
formant.type = "bandpass";
|
|
132
|
+
formant.frequency.value = Math.min(2400, 600 + freq * 2.2);
|
|
133
|
+
formant.Q.value = 0.9;
|
|
134
|
+
const lp = ctx.createBiquadFilter();
|
|
135
|
+
lp.type = "lowpass";
|
|
136
|
+
lp.frequency.value = 2800;
|
|
137
|
+
formant.connect(lp).connect(g).connect(out);
|
|
138
|
+
const vib = ctx.createOscillator();
|
|
139
|
+
vib.frequency.value = 5.2;
|
|
140
|
+
const vibGain = ctx.createGain();
|
|
141
|
+
vibGain.gain.setValueAtTime(0, when);
|
|
142
|
+
vibGain.gain.linearRampToValueAtTime(freq * 0.004, when + 0.3);
|
|
143
|
+
vib.connect(vibGain);
|
|
144
|
+
for (const det of [-5, 4]) {
|
|
145
|
+
const o = ctx.createOscillator();
|
|
146
|
+
o.type = "sawtooth";
|
|
147
|
+
o.frequency.value = freq;
|
|
148
|
+
o.detune.value = det;
|
|
149
|
+
vibGain.connect(o.frequency);
|
|
150
|
+
o.connect(formant);
|
|
151
|
+
o.start(when);
|
|
152
|
+
o.stop(when + dur + 0.4);
|
|
153
|
+
}
|
|
154
|
+
vib.start(when);
|
|
155
|
+
vib.stop(when + dur + 0.4);
|
|
156
|
+
}
|
|
157
|
+
function pluck(ctx, when, freq, vel, out, decay, bassy = false) {
|
|
158
|
+
const g = ctx.createGain();
|
|
159
|
+
const peak = vel * (bassy ? 0.26 : 0.2);
|
|
160
|
+
g.gain.setValueAtTime(0.0001, when);
|
|
161
|
+
g.gain.linearRampToValueAtTime(peak, when + 0.004);
|
|
162
|
+
g.gain.exponentialRampToValueAtTime(0.0001, when + decay);
|
|
163
|
+
const lp = ctx.createBiquadFilter();
|
|
164
|
+
lp.type = "lowpass";
|
|
165
|
+
lp.frequency.value = bassy ? 900 : 2600 + freq;
|
|
166
|
+
lp.connect(g).connect(out);
|
|
167
|
+
const o = ctx.createOscillator();
|
|
168
|
+
o.type = bassy ? "triangle" : "sawtooth";
|
|
169
|
+
o.frequency.value = freq;
|
|
170
|
+
o.connect(lp);
|
|
171
|
+
o.start(when);
|
|
172
|
+
o.stop(when + decay + 0.1);
|
|
173
|
+
if (!bassy) {
|
|
174
|
+
const o2 = ctx.createOscillator();
|
|
175
|
+
o2.type = "triangle";
|
|
176
|
+
o2.frequency.value = freq * 2.001;
|
|
177
|
+
const g2 = ctx.createGain();
|
|
178
|
+
g2.gain.setValueAtTime(peak * 0.4, when);
|
|
179
|
+
g2.gain.exponentialRampToValueAtTime(0.0001, when + decay * 0.6);
|
|
180
|
+
o2.connect(g2).connect(out);
|
|
181
|
+
o2.start(when);
|
|
182
|
+
o2.stop(when + decay);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function bell(ctx, when, freq, vel, out, decay = 1.8) {
|
|
186
|
+
for (const [ratio, amp] of [[1, 1], [2.76, 0.5], [5.4, 0.25], [8.9, 0.12]]) {
|
|
187
|
+
const g = ctx.createGain();
|
|
188
|
+
g.gain.setValueAtTime(0.0001, when);
|
|
189
|
+
g.gain.linearRampToValueAtTime(vel * 0.14 * amp, when + 0.005);
|
|
190
|
+
g.gain.exponentialRampToValueAtTime(0.0001, when + decay * (1 - (ratio - 1) * 0.06));
|
|
191
|
+
const o = ctx.createOscillator();
|
|
192
|
+
o.type = "sine";
|
|
193
|
+
o.frequency.value = freq * ratio;
|
|
194
|
+
o.connect(g).connect(out);
|
|
195
|
+
o.start(when);
|
|
196
|
+
o.stop(when + decay + 0.2);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function piano(ctx, when, freq, dur, vel, out) {
|
|
200
|
+
const naturalDecay = Math.min(5.2, Math.max(1.2, 380 / freq));
|
|
201
|
+
const body = ctx.createBiquadFilter();
|
|
202
|
+
body.type = "lowpass";
|
|
203
|
+
body.frequency.value = Math.min(5600, 1400 + freq * 4);
|
|
204
|
+
body.Q.value = 0.35;
|
|
205
|
+
body.connect(out);
|
|
206
|
+
const partials = [
|
|
207
|
+
[1, 0.62, 1, -3],
|
|
208
|
+
[1.0005, 0.62, 1, 3],
|
|
209
|
+
[2.003, 0.5, 0.58, 2],
|
|
210
|
+
[3.006, 0.2, 0.36, -4],
|
|
211
|
+
[4.012, 0.09, 0.24, 5],
|
|
212
|
+
[5.02, 0.05, 0.17, -6],
|
|
213
|
+
[7.03, 0.025, 0.12, 4],
|
|
214
|
+
];
|
|
215
|
+
for (const [ratio, amp, decayMul, cents] of partials) {
|
|
216
|
+
const decay = Math.min(naturalDecay * decayMul, dur + 0.35);
|
|
217
|
+
const g = ctx.createGain();
|
|
218
|
+
const peak = vel * 0.24 * amp;
|
|
219
|
+
g.gain.setValueAtTime(0.0001, when);
|
|
220
|
+
g.gain.linearRampToValueAtTime(peak, when + 0.004);
|
|
221
|
+
g.gain.exponentialRampToValueAtTime(0.0001, when + Math.max(0.14, decay));
|
|
222
|
+
const o = ctx.createOscillator();
|
|
223
|
+
o.type = ratio < 1.01 ? "triangle" : "sine";
|
|
224
|
+
o.frequency.value = freq * ratio;
|
|
225
|
+
o.detune.value = cents;
|
|
226
|
+
o.connect(g).connect(body);
|
|
227
|
+
o.start(when);
|
|
228
|
+
o.stop(when + Math.max(0.14, decay) + 0.1);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function noiseHit(ctx, when, vel, out, hp, len, curve) {
|
|
232
|
+
const n = Math.floor(ctx.sampleRate * len);
|
|
233
|
+
const buf = ctx.createBuffer(1, n, ctx.sampleRate);
|
|
234
|
+
const data = buf.getChannelData(0);
|
|
235
|
+
for (let i = 0; i < n; i += 1)
|
|
236
|
+
data[i] = (Math.random() * 2 - 1) * (1 - i / n) ** curve;
|
|
237
|
+
const src = ctx.createBufferSource();
|
|
238
|
+
src.buffer = buf;
|
|
239
|
+
const f = ctx.createBiquadFilter();
|
|
240
|
+
f.type = "highpass";
|
|
241
|
+
f.frequency.value = hp;
|
|
242
|
+
const g = ctx.createGain();
|
|
243
|
+
g.gain.setValueAtTime(vel * 0.22, when);
|
|
244
|
+
g.gain.exponentialRampToValueAtTime(0.0001, when + len);
|
|
245
|
+
src.connect(f).connect(g).connect(out);
|
|
246
|
+
src.start(when);
|
|
247
|
+
}
|
|
248
|
+
function drum(ctx, when, freq, vel, out, decay, punch) {
|
|
249
|
+
const o = ctx.createOscillator();
|
|
250
|
+
o.type = "sine";
|
|
251
|
+
o.frequency.setValueAtTime(freq * punch, when);
|
|
252
|
+
o.frequency.exponentialRampToValueAtTime(freq, when + 0.06);
|
|
253
|
+
const g = ctx.createGain();
|
|
254
|
+
g.gain.setValueAtTime(vel * 0.5, when);
|
|
255
|
+
g.gain.exponentialRampToValueAtTime(0.0001, when + decay);
|
|
256
|
+
o.connect(g).connect(out);
|
|
257
|
+
o.start(when);
|
|
258
|
+
o.stop(when + decay + 0.05);
|
|
259
|
+
noiseHit(ctx, when, vel * 0.4, out, 200, Math.min(decay, 0.12), 2);
|
|
260
|
+
}
|
|
261
|
+
function woodBlock(ctx, when, vel, out) {
|
|
262
|
+
const o = ctx.createOscillator();
|
|
263
|
+
o.type = "square";
|
|
264
|
+
o.frequency.value = 1100;
|
|
265
|
+
const g = ctx.createGain();
|
|
266
|
+
g.gain.setValueAtTime(vel * 0.2, when);
|
|
267
|
+
g.gain.exponentialRampToValueAtTime(0.0001, when + 0.05);
|
|
268
|
+
const bp = ctx.createBiquadFilter();
|
|
269
|
+
bp.type = "bandpass";
|
|
270
|
+
bp.frequency.value = 1100;
|
|
271
|
+
o.connect(bp).connect(g).connect(out);
|
|
272
|
+
o.start(when);
|
|
273
|
+
o.stop(when + 0.07);
|
|
274
|
+
}
|
|
275
|
+
function cymSwell(ctx, when, dur, vel, out) {
|
|
276
|
+
const n = Math.floor(ctx.sampleRate * (dur + 0.3));
|
|
277
|
+
const buf = ctx.createBuffer(1, n, ctx.sampleRate);
|
|
278
|
+
const data = buf.getChannelData(0);
|
|
279
|
+
for (let i = 0; i < n; i += 1)
|
|
280
|
+
data[i] = Math.random() * 2 - 1;
|
|
281
|
+
const src = ctx.createBufferSource();
|
|
282
|
+
src.buffer = buf;
|
|
283
|
+
const hp = ctx.createBiquadFilter();
|
|
284
|
+
hp.type = "highpass";
|
|
285
|
+
hp.frequency.value = 6000;
|
|
286
|
+
const g = ctx.createGain();
|
|
287
|
+
g.gain.setValueAtTime(0.0001, when);
|
|
288
|
+
g.gain.linearRampToValueAtTime(vel * 0.1, when + dur);
|
|
289
|
+
g.gain.exponentialRampToValueAtTime(0.0001, when + dur + 0.3);
|
|
290
|
+
src.connect(hp).connect(g).connect(out);
|
|
291
|
+
src.start(when);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Play one theme note through its instrument voice into `out` (a per-layer gain
|
|
295
|
+
* node the director wires to master + reverb). `spb` is seconds-per-beat and
|
|
296
|
+
* `transpose` shifts the note in semitones for per-zone key changes. This is the
|
|
297
|
+
* engine's reusable instrument library; unknown instruments fall back to a sine.
|
|
298
|
+
*/
|
|
299
|
+
export function playMusicNote(ctx, event, when, spb, out, transpose = 0) {
|
|
300
|
+
const freq = mtof(event.midi + transpose);
|
|
301
|
+
const dur = Math.max(0.1, event.dur * spb);
|
|
302
|
+
const vel = event.vel;
|
|
303
|
+
const inst = event.inst;
|
|
304
|
+
switch (inst) {
|
|
305
|
+
case "strings": return strings(ctx, when, freq, dur, vel, out);
|
|
306
|
+
case "stacc": return strings(ctx, when, freq, Math.min(dur, 0.22), vel, out, 0.02);
|
|
307
|
+
case "pad": return pad(ctx, when, freq, dur, vel, out);
|
|
308
|
+
case "reed": return reed(ctx, when, freq, dur, vel, out);
|
|
309
|
+
case "horn": return horn(ctx, when, freq, dur, vel, out);
|
|
310
|
+
case "brassStab": return brassStab(ctx, when, freq, dur, vel, out);
|
|
311
|
+
case "flute": return flute(ctx, when, freq, dur, vel, out);
|
|
312
|
+
case "pipe": return pipe(ctx, when, freq, dur, vel, out);
|
|
313
|
+
case "squareLead": return squareLead(ctx, when, freq, dur, vel, out);
|
|
314
|
+
case "choir": return choir(ctx, when, freq, dur, vel, out);
|
|
315
|
+
case "oboe": return oboe(ctx, when, freq, dur, vel, out);
|
|
316
|
+
case "harp": return pluck(ctx, when, freq, vel, out, 1.4);
|
|
317
|
+
case "lute": return pluck(ctx, when, freq, vel, out, 0.7);
|
|
318
|
+
case "dulcimer": return pluck(ctx, when, freq, vel, out, 1.0);
|
|
319
|
+
case "bass": return pluck(ctx, when, freq, vel, out, 0.9, true);
|
|
320
|
+
case "bell": return bell(ctx, when, freq, vel, out);
|
|
321
|
+
case "tinyBell": return bell(ctx, when, freq, vel, out, 0.7);
|
|
322
|
+
case "piano": return piano(ctx, when, freq, dur, vel, out);
|
|
323
|
+
case "timpani": return drum(ctx, when, freq, vel, out, 0.6, 1.5);
|
|
324
|
+
case "warDrum": return drum(ctx, when, Math.max(50, freq * 0.5), vel, out, 0.35, 1.8);
|
|
325
|
+
case "frameDrum": return drum(ctx, when, Math.max(70, freq * 0.6), vel, out, 0.25, 1.6);
|
|
326
|
+
case "shaker": return noiseHit(ctx, when, vel, out, 6800, 0.055, 1.8);
|
|
327
|
+
case "woodBlock": return woodBlock(ctx, when, vel, out);
|
|
328
|
+
case "cymSwell": return cymSwell(ctx, when, dur, vel, out);
|
|
329
|
+
default: {
|
|
330
|
+
const o = ctx.createOscillator();
|
|
331
|
+
o.frequency.value = freq;
|
|
332
|
+
const g = adsr(ctx, when, dur, vel * 0.12, 0.02, 0.2);
|
|
333
|
+
o.connect(g).connect(out);
|
|
334
|
+
o.start(when);
|
|
335
|
+
o.stop(when + dur + 0.3);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SynthPatch } from "@jgengine/core/audio/synth";
|
|
2
|
+
/** Build the shared 1-second mono white-noise buffer every noise voice samples from. */
|
|
3
|
+
export declare function createNoiseBuffer(ctx: BaseAudioContext): AudioBuffer;
|
|
4
|
+
/**
|
|
5
|
+
* Realise a procedural cue on Web Audio: every voice is scheduled at
|
|
6
|
+
* `ctx.currentTime + delay` into `out`, summed into one one-shot. `noiseBuf` is
|
|
7
|
+
* the shared buffer from {@link createNoiseBuffer}.
|
|
8
|
+
*/
|
|
9
|
+
export declare function realizeSynthPatch(ctx: BaseAudioContext, out: AudioNode, noiseBuf: AudioBuffer, patch: SynthPatch): void;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Build the shared 1-second mono white-noise buffer every noise voice samples from. */
|
|
2
|
+
export function createNoiseBuffer(ctx) {
|
|
3
|
+
const len = Math.floor(ctx.sampleRate);
|
|
4
|
+
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
|
|
5
|
+
const data = buf.getChannelData(0);
|
|
6
|
+
for (let i = 0; i < len; i += 1)
|
|
7
|
+
data[i] = Math.random() * 2 - 1;
|
|
8
|
+
return buf;
|
|
9
|
+
}
|
|
10
|
+
function realizeTone(ctx, out, voice, cueGain, at) {
|
|
11
|
+
const t = at + (voice.delay ?? 0);
|
|
12
|
+
const peak = (voice.gain ?? 1) * cueGain;
|
|
13
|
+
const attack = voice.attack ?? 0.012;
|
|
14
|
+
const osc = ctx.createOscillator();
|
|
15
|
+
osc.type = voice.wave ?? "sine";
|
|
16
|
+
osc.frequency.setValueAtTime(voice.freq, t);
|
|
17
|
+
if (voice.slideTo !== undefined)
|
|
18
|
+
osc.frequency.exponentialRampToValueAtTime(Math.max(1, voice.slideTo), t + voice.duration);
|
|
19
|
+
const g = ctx.createGain();
|
|
20
|
+
g.gain.setValueAtTime(0, t);
|
|
21
|
+
g.gain.linearRampToValueAtTime(peak, t + attack);
|
|
22
|
+
g.gain.exponentialRampToValueAtTime(0.001, t + voice.duration);
|
|
23
|
+
osc.connect(g).connect(out);
|
|
24
|
+
osc.start(t);
|
|
25
|
+
osc.stop(t + voice.duration + 0.05);
|
|
26
|
+
}
|
|
27
|
+
function realizeNoise(ctx, out, noiseBuf, voice, cueGain, at) {
|
|
28
|
+
const t = at + (voice.delay ?? 0);
|
|
29
|
+
const peak = (voice.gain ?? 1) * cueGain;
|
|
30
|
+
const decay = voice.decay ?? 0.9;
|
|
31
|
+
const src = ctx.createBufferSource();
|
|
32
|
+
src.buffer = noiseBuf;
|
|
33
|
+
src.playbackRate.value = 0.8 + Math.random() * 0.4;
|
|
34
|
+
const filter = ctx.createBiquadFilter();
|
|
35
|
+
filter.type = voice.filterType ?? "lowpass";
|
|
36
|
+
filter.frequency.value = voice.filterFreq;
|
|
37
|
+
const g = ctx.createGain();
|
|
38
|
+
g.gain.setValueAtTime(peak, t);
|
|
39
|
+
g.gain.exponentialRampToValueAtTime(0.001, t + voice.duration * decay);
|
|
40
|
+
src.connect(filter).connect(g).connect(out);
|
|
41
|
+
src.start(t, Math.random() * 0.5, voice.duration);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Realise a procedural cue on Web Audio: every voice is scheduled at
|
|
45
|
+
* `ctx.currentTime + delay` into `out`, summed into one one-shot. `noiseBuf` is
|
|
46
|
+
* the shared buffer from {@link createNoiseBuffer}.
|
|
47
|
+
*/
|
|
48
|
+
export function realizeSynthPatch(ctx, out, noiseBuf, patch) {
|
|
49
|
+
const cueGain = patch.gain ?? 1;
|
|
50
|
+
const at = ctx.currentTime;
|
|
51
|
+
for (const voice of patch.voices) {
|
|
52
|
+
if (voice.kind === "tone")
|
|
53
|
+
realizeTone(ctx, out, voice, cueGain, at);
|
|
54
|
+
else
|
|
55
|
+
realizeNoise(ctx, out, noiseBuf, voice, cueGain, at);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { type MutableRefObject } from "react";
|
|
2
|
+
import * as THREE from "three";
|
|
2
3
|
import type { FirstPersonCameraConfig } from "@jgengine/core/game/playableGame";
|
|
4
|
+
/** World position of the first-person weapon muzzle, or false when no viewmodel is mounted. */
|
|
5
|
+
export declare function readFirstPersonMuzzle(target: THREE.Vector3): boolean;
|
|
3
6
|
export interface GameFirstPersonCameraProps {
|
|
4
7
|
yawRef: MutableRefObject<number>;
|
|
5
8
|
pitchRef: MutableRefObject<number>;
|