@galfus/transport-browser 0.31.0-alpha → 0.31.1-alpha
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/dist/galfus_browser_bg.wasm +0 -0
- package/package.json +2 -2
- package/src/sdk/audio.ts +204 -0
- package/src/sdk/galfus.ts +1805 -0
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@galfus/transport-browser",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.1-alpha",
|
|
4
4
|
"description": "WebAssembly and WebGPU browser transport for Galfus Engine",
|
|
5
5
|
"author": "Renato Rodrigues (morbden) <renato@vulppi.dev>",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -35,6 +35,6 @@
|
|
|
35
35
|
".": "./src/index.ts"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@galfus/transport-types": "^0.31.
|
|
38
|
+
"@galfus/transport-types": "^0.31.1-alpha"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/sdk/audio.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copied to `sdk/browser/audio.ts` by the SDK generator.
|
|
3
|
+
*
|
|
4
|
+
* Web Audio cannot live in the Rust WASM module because decoding and playback are asynchronous
|
|
5
|
+
* browser APIs. It is therefore the browser SDK's platform adapter.
|
|
6
|
+
*/
|
|
7
|
+
interface SpatialAudioState {
|
|
8
|
+
emitter: { position: [number, number, number] };
|
|
9
|
+
listener: { position: [number, number, number] };
|
|
10
|
+
config: {
|
|
11
|
+
volume?: number;
|
|
12
|
+
minDistance?: number;
|
|
13
|
+
maxDistance?: number;
|
|
14
|
+
rolloff?: number;
|
|
15
|
+
speed?: number;
|
|
16
|
+
looping?: boolean;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Voice {
|
|
21
|
+
readonly soundId: number;
|
|
22
|
+
readonly gain: GainNode;
|
|
23
|
+
readonly panner?: StereoPannerNode;
|
|
24
|
+
readonly speed: number;
|
|
25
|
+
readonly looping: boolean;
|
|
26
|
+
source?: AudioBufferSourceNode;
|
|
27
|
+
startedAt?: number;
|
|
28
|
+
offset: number;
|
|
29
|
+
paused: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Web Audio implementation used by the generated Browser/WASM SDK. */
|
|
33
|
+
export class WasmAudio {
|
|
34
|
+
private context?: AudioContext;
|
|
35
|
+
private readonly sounds = new Map<number, Promise<AudioBuffer>>();
|
|
36
|
+
private readonly voices = new Map<number, Voice>();
|
|
37
|
+
private readonly pendingVoiceIds = new Set<number>();
|
|
38
|
+
private nextVoiceId = 1;
|
|
39
|
+
|
|
40
|
+
constructor() {
|
|
41
|
+
if (typeof window !== 'undefined') {
|
|
42
|
+
const unlock = () => void this.enable();
|
|
43
|
+
window.addEventListener('pointerdown', unlock, { once: true, passive: true });
|
|
44
|
+
window.addEventListener('keydown', unlock, { once: true });
|
|
45
|
+
window.addEventListener('touchstart', unlock, { once: true, passive: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Resumes audio from a user gesture, then starts queued voices. */
|
|
50
|
+
async enable(): Promise<void> {
|
|
51
|
+
const context = this.getContext();
|
|
52
|
+
if (context.state !== 'running') await context.resume();
|
|
53
|
+
for (const voiceId of this.pendingVoiceIds) {
|
|
54
|
+
const voice = this.voices.get(voiceId);
|
|
55
|
+
if (voice && !voice.paused) void this.startVoice(voiceId, voice);
|
|
56
|
+
}
|
|
57
|
+
this.pendingVoiceIds.clear();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
load(soundId: number, data: unknown): void {
|
|
61
|
+
const bytes = this.toBytes(data);
|
|
62
|
+
const decoded = this.getContext().decodeAudioData(Uint8Array.from(bytes).buffer);
|
|
63
|
+
this.sounds.set(soundId, decoded);
|
|
64
|
+
void decoded.catch((error: unknown) => {
|
|
65
|
+
this.sounds.delete(soundId);
|
|
66
|
+
console.warn(`[Galfus Audio] Unable to decode sound ${soundId}:`, error);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
play(soundId: number, volume: number, panning: number, speed: number, looping: boolean): number {
|
|
71
|
+
const context = this.getContext();
|
|
72
|
+
const gain = context.createGain();
|
|
73
|
+
gain.gain.value = Math.max(0, volume);
|
|
74
|
+
const panner = context.createStereoPanner?.();
|
|
75
|
+
if (panner) panner.pan.value = Math.max(-1, Math.min(1, panning));
|
|
76
|
+
gain.connect(panner ?? context.destination);
|
|
77
|
+
if (panner) panner.connect(context.destination);
|
|
78
|
+
|
|
79
|
+
const voiceId = this.nextVoiceId++;
|
|
80
|
+
this.voices.set(voiceId, {
|
|
81
|
+
soundId,
|
|
82
|
+
gain,
|
|
83
|
+
panner,
|
|
84
|
+
speed: Math.max(0.01, speed),
|
|
85
|
+
looping,
|
|
86
|
+
offset: 0,
|
|
87
|
+
paused: false
|
|
88
|
+
});
|
|
89
|
+
void this.startVoice(voiceId, this.voices.get(voiceId)!);
|
|
90
|
+
return voiceId;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
playSpatial(soundId: number, spatial: SpatialAudioState): number {
|
|
94
|
+
const { volume, panning } = this.spatialValues(spatial);
|
|
95
|
+
return this.play(
|
|
96
|
+
soundId,
|
|
97
|
+
volume,
|
|
98
|
+
panning,
|
|
99
|
+
spatial.config.speed ?? 1,
|
|
100
|
+
spatial.config.looping ?? false
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
updateSpatial(voiceId: number, spatial: SpatialAudioState): void {
|
|
105
|
+
const voice = this.voices.get(voiceId);
|
|
106
|
+
if (!voice) return;
|
|
107
|
+
const { volume, panning } = this.spatialValues(spatial);
|
|
108
|
+
voice.gain.gain.value = volume;
|
|
109
|
+
if (voice.panner) voice.panner.pan.value = panning;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
pause(voiceId: number): void {
|
|
113
|
+
const voice = this.voices.get(voiceId);
|
|
114
|
+
if (!voice?.source || !this.context || voice.paused) return;
|
|
115
|
+
voice.offset +=
|
|
116
|
+
(this.context.currentTime - (voice.startedAt ?? this.context.currentTime)) * voice.speed;
|
|
117
|
+
voice.paused = true;
|
|
118
|
+
voice.source.stop();
|
|
119
|
+
voice.source = undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
resume(voiceId: number): void {
|
|
123
|
+
const voice = this.voices.get(voiceId);
|
|
124
|
+
if (!voice || !voice.paused) return;
|
|
125
|
+
voice.paused = false;
|
|
126
|
+
void this.startVoice(voiceId, voice);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
stop(voiceId: number): void {
|
|
130
|
+
const voice = this.voices.get(voiceId);
|
|
131
|
+
if (!voice) return;
|
|
132
|
+
voice.source?.stop();
|
|
133
|
+
voice.gain.disconnect();
|
|
134
|
+
this.pendingVoiceIds.delete(voiceId);
|
|
135
|
+
this.voices.delete(voiceId);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
setVolume(voiceId: number, volume: number): void {
|
|
139
|
+
const voice = this.voices.get(voiceId);
|
|
140
|
+
if (voice) voice.gain.gain.value = Math.max(0, volume);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
setPanning(voiceId: number, panning: number): void {
|
|
144
|
+
const voice = this.voices.get(voiceId);
|
|
145
|
+
if (voice?.panner) voice.panner.pan.value = Math.max(-1, Math.min(1, panning));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
unload(soundId: number): void {
|
|
149
|
+
this.sounds.delete(soundId);
|
|
150
|
+
for (const [voiceId, voice] of this.voices) if (voice.soundId === soundId) this.stop(voiceId);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private getContext(): AudioContext {
|
|
154
|
+
if (!this.context) this.context = new AudioContext();
|
|
155
|
+
return this.context;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private async startVoice(voiceId: number, voice: Voice): Promise<void> {
|
|
159
|
+
const context = this.getContext();
|
|
160
|
+
if (context.state !== 'running') {
|
|
161
|
+
this.pendingVoiceIds.add(voiceId);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const sound = this.sounds.get(voice.soundId);
|
|
165
|
+
if (!sound) return console.warn(`[Galfus Audio] Sound ${voice.soundId} is not loaded.`);
|
|
166
|
+
const buffer = await sound;
|
|
167
|
+
if (voice.paused || !this.voices.has(voiceId)) return;
|
|
168
|
+
|
|
169
|
+
const source = context.createBufferSource();
|
|
170
|
+
source.buffer = buffer;
|
|
171
|
+
source.loop = voice.looping;
|
|
172
|
+
source.playbackRate.value = voice.speed;
|
|
173
|
+
source.connect(voice.gain);
|
|
174
|
+
source.onended = () => {
|
|
175
|
+
if (!voice.paused && voice.source === source) this.stop(voiceId);
|
|
176
|
+
};
|
|
177
|
+
voice.source = source;
|
|
178
|
+
voice.startedAt = context.currentTime;
|
|
179
|
+
source.start(0, voice.offset);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private spatialValues(spatial: SpatialAudioState): { volume: number; panning: number } {
|
|
183
|
+
const [ex, ey, ez] = spatial.emitter.position;
|
|
184
|
+
const [lx, ly, lz] = spatial.listener.position;
|
|
185
|
+
const distance = Math.hypot(ex - lx, ey - ly, ez - lz);
|
|
186
|
+
const min = spatial.config.minDistance ?? 1;
|
|
187
|
+
const max = Math.max(spatial.config.maxDistance ?? 50, min + 0.001);
|
|
188
|
+
const normalized = Math.min(1, Math.max(0, (distance - min) / (max - min)));
|
|
189
|
+
const volume =
|
|
190
|
+
(spatial.config.volume ?? 1) * (1 - normalized) ** Math.max(spatial.config.rolloff ?? 1, 0.1);
|
|
191
|
+
return {
|
|
192
|
+
volume,
|
|
193
|
+
panning: distance > 0.001 ? Math.max(-1, Math.min(1, (ex - lx) / distance)) : 0
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private toBytes(data: unknown): Uint8Array {
|
|
198
|
+
if (data instanceof Uint8Array) return data;
|
|
199
|
+
if (ArrayBuffer.isView(data))
|
|
200
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
201
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
202
|
+
throw new TypeError('Browser audio requires an ArrayBuffer or Uint8Array payload.');
|
|
203
|
+
}
|
|
204
|
+
}
|