@relu-interactives/spatial-ecs 0.9.0 → 0.11.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/dist/{System-8D-XBDKZ.d.cts → System-BldSHxZV.d.cts} +214 -81
- package/dist/{System-DPMAW30g.d.ts → System-CADhPyQs.d.ts} +214 -81
- package/dist/{XRHandPose-CWSrsnYN.d.ts → XRHandPose-BNSwd1y9.d.ts} +1 -1
- package/dist/{XRHandPose-Dn2yiALk.d.cts → XRHandPose-D3Ab1Dfs.d.cts} +1 -1
- package/dist/{chunk-VYIWOXAY.js → chunk-TY3XCHOR.js} +18 -10
- package/dist/chunk-TY3XCHOR.js.map +1 -0
- package/dist/components/index.d.cts +3 -3
- package/dist/components/index.d.ts +3 -3
- package/dist/docs/guide/bone-attachments-and-ik.md +1 -1
- package/dist/docs/guide/multiplayer.md +65 -0
- package/dist/factories/index.d.cts +2 -2
- package/dist/factories/index.d.ts +2 -2
- package/dist/{index-D5MmJNyj.d.ts → index-6pZO_gV7.d.ts} +1 -1
- package/dist/{index-CAZHrh0q.d.cts → index-BXjwEdNq.d.cts} +2 -2
- package/dist/{index-DiIjV2lm.d.cts → index-BdghvXKE.d.cts} +1 -1
- package/dist/{index-CLtSMf2M.d.ts → index-CVG-hREM.d.ts} +2 -2
- package/dist/index.cjs +16 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -30
- package/dist/index.d.ts +8 -30
- package/dist/index.js +2 -2
- package/dist/network/index.cjs +681 -4
- package/dist/network/index.cjs.map +1 -1
- package/dist/network/index.d.cts +126 -5
- package/dist/network/index.d.ts +126 -5
- package/dist/network/index.js +680 -5
- package/dist/network/index.js.map +1 -1
- package/dist/systems/index.cjs +16 -8
- package/dist/systems/index.cjs.map +1 -1
- package/dist/systems/index.d.cts +2 -2
- package/dist/systems/index.d.ts +2 -2
- package/dist/systems/index.js +1 -1
- package/dist/types.d.cts +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/webgpu/index.d.ts +2 -2
- package/dist/worldSnapshotUtils.d.cts +1 -1
- package/dist/worldSnapshotUtils.d.ts +1 -1
- package/package.json +1 -1
- package/dist/chunk-VYIWOXAY.js.map +0 -1
package/dist/network/index.cjs
CHANGED
|
@@ -87,6 +87,12 @@ var _ColyseusTransport = class _ColyseusTransport {
|
|
|
87
87
|
onCorrect(cb) {
|
|
88
88
|
this.room.onMessage("correct", (m) => cb(m.id, m));
|
|
89
89
|
}
|
|
90
|
+
onSound(cb) {
|
|
91
|
+
this.room.onMessage("sound", (m) => cb(m, m.from));
|
|
92
|
+
}
|
|
93
|
+
onVoice(cb) {
|
|
94
|
+
this.room.onMessage("voice", cb);
|
|
95
|
+
}
|
|
90
96
|
onClose(cb) {
|
|
91
97
|
this.room.onLeave(() => cb());
|
|
92
98
|
}
|
|
@@ -108,6 +114,12 @@ var _ColyseusTransport = class _ColyseusTransport {
|
|
|
108
114
|
send(name, payload) {
|
|
109
115
|
this.room.send("m", { name, payload });
|
|
110
116
|
}
|
|
117
|
+
sendSound(sound) {
|
|
118
|
+
this.room.send("sound", sound);
|
|
119
|
+
}
|
|
120
|
+
sendVoice(message) {
|
|
121
|
+
this.room.send("voice", message);
|
|
122
|
+
}
|
|
111
123
|
leave() {
|
|
112
124
|
void this.room.leave();
|
|
113
125
|
}
|
|
@@ -4745,6 +4757,623 @@ var _SnapshotBuffer = class _SnapshotBuffer {
|
|
|
4745
4757
|
__name(_SnapshotBuffer, "SnapshotBuffer");
|
|
4746
4758
|
var SnapshotBuffer = _SnapshotBuffer;
|
|
4747
4759
|
|
|
4760
|
+
// core/network/sdp.ts
|
|
4761
|
+
var OPUS_VOICE = {
|
|
4762
|
+
useinbandfec: "1",
|
|
4763
|
+
usedtx: "1",
|
|
4764
|
+
stereo: "0",
|
|
4765
|
+
"sprop-stereo": "0",
|
|
4766
|
+
maxaveragebitrate: "20000",
|
|
4767
|
+
maxplaybackrate: "24000"
|
|
4768
|
+
};
|
|
4769
|
+
var VOICE_PTIME = 40;
|
|
4770
|
+
function mergeFmtp(fmtp, params) {
|
|
4771
|
+
const pairs = fmtp.split(";").map((part) => part.trim()).filter(Boolean).map((part) => {
|
|
4772
|
+
const i = part.indexOf("=");
|
|
4773
|
+
return i < 0 ? [part, ""] : [part.slice(0, i), part.slice(i + 1)];
|
|
4774
|
+
});
|
|
4775
|
+
for (const [key, value] of Object.entries(params)) {
|
|
4776
|
+
const found = pairs.find((pair) => pair[0].toLowerCase() === key.toLowerCase());
|
|
4777
|
+
if (found) found[1] = value;
|
|
4778
|
+
else pairs.push([key, value]);
|
|
4779
|
+
}
|
|
4780
|
+
return pairs.map(([k, v]) => v === "" ? k : `${k}=${v}`).join(";");
|
|
4781
|
+
}
|
|
4782
|
+
__name(mergeFmtp, "mergeFmtp");
|
|
4783
|
+
function tuneOpus(sdp, params = OPUS_VOICE, ptime = VOICE_PTIME) {
|
|
4784
|
+
const eol = sdp.includes("\r\n") ? "\r\n" : "\n";
|
|
4785
|
+
const sections = [[]];
|
|
4786
|
+
for (const line of sdp.split(eol)) {
|
|
4787
|
+
if (line.startsWith("m=")) sections.push([]);
|
|
4788
|
+
sections[sections.length - 1].push(line);
|
|
4789
|
+
}
|
|
4790
|
+
const out = [];
|
|
4791
|
+
for (const section of sections) {
|
|
4792
|
+
const opus = section.flatMap((line) => {
|
|
4793
|
+
const match = /^a=rtpmap:(\d+) opus\/48000/i.exec(line);
|
|
4794
|
+
return match ? [match[1]] : [];
|
|
4795
|
+
});
|
|
4796
|
+
if (!section[0]?.startsWith("m=audio") || !opus.length) {
|
|
4797
|
+
out.push(...section);
|
|
4798
|
+
continue;
|
|
4799
|
+
}
|
|
4800
|
+
const tuned = [];
|
|
4801
|
+
for (const line of section) {
|
|
4802
|
+
const fmtp = /^a=fmtp:(\d+) (.*)$/.exec(line);
|
|
4803
|
+
if (fmtp && opus.includes(fmtp[1])) {
|
|
4804
|
+
tuned.push(`a=fmtp:${fmtp[1]} ${mergeFmtp(fmtp[2], params)}`);
|
|
4805
|
+
continue;
|
|
4806
|
+
}
|
|
4807
|
+
tuned.push(line);
|
|
4808
|
+
const rtpmap = /^a=rtpmap:(\d+) opus\/48000/i.exec(line);
|
|
4809
|
+
if (rtpmap && !section.some((l) => l.startsWith(`a=fmtp:${rtpmap[1]} `))) {
|
|
4810
|
+
tuned.push(`a=fmtp:${rtpmap[1]} ${mergeFmtp("", params)}`);
|
|
4811
|
+
}
|
|
4812
|
+
}
|
|
4813
|
+
if (ptime && !tuned.some((line) => line.startsWith("a=ptime:"))) {
|
|
4814
|
+
const at = tuned[tuned.length - 1] === "" ? tuned.length - 1 : tuned.length;
|
|
4815
|
+
tuned.splice(at, 0, `a=ptime:${ptime}`);
|
|
4816
|
+
}
|
|
4817
|
+
out.push(...tuned);
|
|
4818
|
+
}
|
|
4819
|
+
return out.join(eol);
|
|
4820
|
+
}
|
|
4821
|
+
__name(tuneOpus, "tuneOpus");
|
|
4822
|
+
|
|
4823
|
+
// core/network/voice.ts
|
|
4824
|
+
var LEVEL_BPS = [24e3, 16e3, 1e4];
|
|
4825
|
+
var WORST_LEVEL = 2;
|
|
4826
|
+
var TICK_MS = 2e3;
|
|
4827
|
+
var POLL_MS = 100;
|
|
4828
|
+
var GOOD_TO_STEP_UP_MS = 1e4;
|
|
4829
|
+
var MAX_LOSS = 0.05;
|
|
4830
|
+
var MAX_RTT_MS = 400;
|
|
4831
|
+
var TALKING_LEVEL = 0.06;
|
|
4832
|
+
var DUCK_SECONDS = 0.6;
|
|
4833
|
+
var MAX_RESTARTS = 3;
|
|
4834
|
+
function startingLevel(others) {
|
|
4835
|
+
if (others <= 2) return 0;
|
|
4836
|
+
return others <= 4 ? 1 : 2;
|
|
4837
|
+
}
|
|
4838
|
+
__name(startingLevel, "startingLevel");
|
|
4839
|
+
function adaptLevel(level, best, bad, goodMs) {
|
|
4840
|
+
let next = Math.max(level, best);
|
|
4841
|
+
if (bad) {
|
|
4842
|
+
return { level: Math.min(WORST_LEVEL, next + 1), goodMs: 0 };
|
|
4843
|
+
}
|
|
4844
|
+
let good = goodMs + TICK_MS;
|
|
4845
|
+
if (good >= GOOD_TO_STEP_UP_MS && next > best) {
|
|
4846
|
+
next -= 1;
|
|
4847
|
+
good = 0;
|
|
4848
|
+
}
|
|
4849
|
+
return { level: next, goodMs: good };
|
|
4850
|
+
}
|
|
4851
|
+
__name(adaptLevel, "adaptLevel");
|
|
4852
|
+
function micError(error) {
|
|
4853
|
+
const name = error.name;
|
|
4854
|
+
if (name === "NotAllowedError") return "Microphone blocked: allow it in your browser settings";
|
|
4855
|
+
if (name === "NotFoundError") return "No microphone found";
|
|
4856
|
+
return "Could not start the microphone";
|
|
4857
|
+
}
|
|
4858
|
+
__name(micError, "micError");
|
|
4859
|
+
async function readLinkStats(pc) {
|
|
4860
|
+
const stats = await pc.getStats();
|
|
4861
|
+
let loss = 0;
|
|
4862
|
+
let rttMs = 0;
|
|
4863
|
+
stats.forEach((report) => {
|
|
4864
|
+
const r = report;
|
|
4865
|
+
if (r.type === "remote-inbound-rtp" && r.kind === "audio") {
|
|
4866
|
+
if (typeof r.fractionLost === "number") loss = Math.max(loss, r.fractionLost);
|
|
4867
|
+
}
|
|
4868
|
+
if (r.type === "candidate-pair" && r.state === "succeeded" && r.nominated !== false) {
|
|
4869
|
+
if (typeof r.currentRoundTripTime === "number") {
|
|
4870
|
+
rttMs = Math.max(rttMs, r.currentRoundTripTime * 1e3);
|
|
4871
|
+
}
|
|
4872
|
+
}
|
|
4873
|
+
});
|
|
4874
|
+
return { loss, rttMs };
|
|
4875
|
+
}
|
|
4876
|
+
__name(readLinkStats, "readLinkStats");
|
|
4877
|
+
var _VoiceChat = class _VoiceChat {
|
|
4878
|
+
constructor(transport, deps) {
|
|
4879
|
+
this.transport = transport;
|
|
4880
|
+
this.deps = deps;
|
|
4881
|
+
this.available = false;
|
|
4882
|
+
this.state = "off";
|
|
4883
|
+
this.micMuted = false;
|
|
4884
|
+
this.talking = false;
|
|
4885
|
+
this.error = "";
|
|
4886
|
+
/** Hear each voice from its character, when there is one. Off, everyone's in your ear. */
|
|
4887
|
+
this.positional = true;
|
|
4888
|
+
this.peersById = /* @__PURE__ */ new Map();
|
|
4889
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
4890
|
+
this.config = {};
|
|
4891
|
+
this.track = null;
|
|
4892
|
+
this.meter = null;
|
|
4893
|
+
this.level = 0;
|
|
4894
|
+
this.goodMs = 0;
|
|
4895
|
+
this.stopTimers = null;
|
|
4896
|
+
this.disposed = false;
|
|
4897
|
+
transport.onVoice((message) => this.fromRoom(message));
|
|
4898
|
+
transport.sendVoice({ t: "hello" });
|
|
4899
|
+
}
|
|
4900
|
+
get peers() {
|
|
4901
|
+
return [...this.peersById.values()].map((p) => ({
|
|
4902
|
+
id: p.id,
|
|
4903
|
+
connected: p.connected,
|
|
4904
|
+
talking: p.talking,
|
|
4905
|
+
micOn: p.micOn,
|
|
4906
|
+
muted: p.muted,
|
|
4907
|
+
volume: p.volume
|
|
4908
|
+
}));
|
|
4909
|
+
}
|
|
4910
|
+
onChange(listener) {
|
|
4911
|
+
this.listeners.add(listener);
|
|
4912
|
+
return () => {
|
|
4913
|
+
this.listeners.delete(listener);
|
|
4914
|
+
};
|
|
4915
|
+
}
|
|
4916
|
+
changed() {
|
|
4917
|
+
for (const listener of this.listeners) listener();
|
|
4918
|
+
}
|
|
4919
|
+
/** Turn voice on. Call from a tap or key: that's what lets the browser ask for the mic. */
|
|
4920
|
+
async enable() {
|
|
4921
|
+
if (this.state !== "off") return true;
|
|
4922
|
+
if (!this.available) return false;
|
|
4923
|
+
this.error = "";
|
|
4924
|
+
this.state = "connecting";
|
|
4925
|
+
this.changed();
|
|
4926
|
+
this.deps.setCapture(true);
|
|
4927
|
+
try {
|
|
4928
|
+
await this.acquire();
|
|
4929
|
+
} catch (error) {
|
|
4930
|
+
this.deps.setCapture(false);
|
|
4931
|
+
this.state = "off";
|
|
4932
|
+
this.error = micError(error);
|
|
4933
|
+
this.changed();
|
|
4934
|
+
return false;
|
|
4935
|
+
}
|
|
4936
|
+
if (this.disposed || this.state !== "connecting") {
|
|
4937
|
+
this.release();
|
|
4938
|
+
return false;
|
|
4939
|
+
}
|
|
4940
|
+
this.micMuted = false;
|
|
4941
|
+
this.transport.sendVoice({ t: "join" });
|
|
4942
|
+
return true;
|
|
4943
|
+
}
|
|
4944
|
+
async acquire() {
|
|
4945
|
+
const stream = await this.deps.getMic({
|
|
4946
|
+
audio: {
|
|
4947
|
+
echoCancellation: true,
|
|
4948
|
+
noiseSuppression: true,
|
|
4949
|
+
autoGainControl: true,
|
|
4950
|
+
channelCount: 1
|
|
4951
|
+
},
|
|
4952
|
+
video: false
|
|
4953
|
+
});
|
|
4954
|
+
this.track = stream.getAudioTracks()[0] ?? null;
|
|
4955
|
+
if (this.track) this.track.onended = () => this.lost();
|
|
4956
|
+
this.meter?.close();
|
|
4957
|
+
this.meter = this.deps.analyse(stream);
|
|
4958
|
+
}
|
|
4959
|
+
release() {
|
|
4960
|
+
try {
|
|
4961
|
+
this.track?.stop();
|
|
4962
|
+
} catch {
|
|
4963
|
+
}
|
|
4964
|
+
this.track = null;
|
|
4965
|
+
this.meter?.close();
|
|
4966
|
+
this.meter = null;
|
|
4967
|
+
this.talking = false;
|
|
4968
|
+
this.deps.setCapture(false);
|
|
4969
|
+
}
|
|
4970
|
+
/** The mic stopped by itself: the phone took it for a call, or the device went away. */
|
|
4971
|
+
lost() {
|
|
4972
|
+
if (this.state === "off") return;
|
|
4973
|
+
this.track = null;
|
|
4974
|
+
this.micMuted = true;
|
|
4975
|
+
this.error = "Your mic stopped: tap the mic to turn it back on";
|
|
4976
|
+
this.transport.sendVoice({ t: "mic", on: false });
|
|
4977
|
+
this.sendMicToAll();
|
|
4978
|
+
this.changed();
|
|
4979
|
+
}
|
|
4980
|
+
/** Turn voice off. The mic stops at once. */
|
|
4981
|
+
disable() {
|
|
4982
|
+
if (this.state === "off") return;
|
|
4983
|
+
this.transport.sendVoice({ t: "leave" });
|
|
4984
|
+
this.shutDown();
|
|
4985
|
+
this.changed();
|
|
4986
|
+
}
|
|
4987
|
+
shutDown() {
|
|
4988
|
+
this.state = "off";
|
|
4989
|
+
this.micMuted = false;
|
|
4990
|
+
this.stopTimers?.();
|
|
4991
|
+
this.stopTimers = null;
|
|
4992
|
+
for (const peer of [...this.peersById.values()]) this.closePeer(peer);
|
|
4993
|
+
this.release();
|
|
4994
|
+
}
|
|
4995
|
+
/** Mute or unmute this player's mic. Everyone in voice sees it. */
|
|
4996
|
+
async setMicMuted(muted) {
|
|
4997
|
+
if (this.state !== "on") return;
|
|
4998
|
+
if (!muted && !this.track) {
|
|
4999
|
+
try {
|
|
5000
|
+
this.deps.setCapture(true);
|
|
5001
|
+
await this.acquire();
|
|
5002
|
+
this.error = "";
|
|
5003
|
+
} catch (error) {
|
|
5004
|
+
this.error = micError(error);
|
|
5005
|
+
this.changed();
|
|
5006
|
+
return;
|
|
5007
|
+
}
|
|
5008
|
+
}
|
|
5009
|
+
this.micMuted = muted;
|
|
5010
|
+
this.transport.sendVoice({ t: "mic", on: !muted });
|
|
5011
|
+
this.sendMicToAll();
|
|
5012
|
+
this.changed();
|
|
5013
|
+
}
|
|
5014
|
+
/** Stop hearing someone. They aren't told. */
|
|
5015
|
+
setPeerMuted(id, muted) {
|
|
5016
|
+
const peer = this.peersById.get(id);
|
|
5017
|
+
if (!peer) return;
|
|
5018
|
+
peer.muted = muted;
|
|
5019
|
+
peer.player?.setVolume(muted ? 0 : peer.volume);
|
|
5020
|
+
this.changed();
|
|
5021
|
+
}
|
|
5022
|
+
setPeerVolume(id, volume) {
|
|
5023
|
+
const peer = this.peersById.get(id);
|
|
5024
|
+
if (!peer) return;
|
|
5025
|
+
peer.volume = volume;
|
|
5026
|
+
if (!peer.muted) peer.player?.setVolume(volume);
|
|
5027
|
+
this.changed();
|
|
5028
|
+
}
|
|
5029
|
+
fromRoom(m) {
|
|
5030
|
+
if (m.t === "caps") {
|
|
5031
|
+
this.available = m.available && this.deps.supported();
|
|
5032
|
+
this.changed();
|
|
5033
|
+
} else if (m.t === "refused") {
|
|
5034
|
+
if (this.state === "off") return;
|
|
5035
|
+
this.shutDown();
|
|
5036
|
+
this.error = m.reason === "full" ? "Voice is full in this room" : "Voice chat isn't available in this game";
|
|
5037
|
+
this.changed();
|
|
5038
|
+
} else if (m.t === "welcome") {
|
|
5039
|
+
if (this.state !== "connecting") return;
|
|
5040
|
+
this.config = {
|
|
5041
|
+
iceServers: m.iceServers,
|
|
5042
|
+
iceTransportPolicy: m.relayOnly ? "relay" : "all"
|
|
5043
|
+
};
|
|
5044
|
+
this.state = "on";
|
|
5045
|
+
this.level = startingLevel(m.peers.length);
|
|
5046
|
+
this.startTimers();
|
|
5047
|
+
for (const other of m.peers) {
|
|
5048
|
+
void this.call(this.addPeer(other.id, true, other.mic));
|
|
5049
|
+
}
|
|
5050
|
+
this.changed();
|
|
5051
|
+
} else if (this.state !== "on") ; else if (m.t === "joined") {
|
|
5052
|
+
this.addPeer(m.id, false, true);
|
|
5053
|
+
this.retune();
|
|
5054
|
+
this.changed();
|
|
5055
|
+
} else if (m.t === "left") {
|
|
5056
|
+
const peer = this.peersById.get(m.id);
|
|
5057
|
+
if (peer) this.closePeer(peer);
|
|
5058
|
+
this.retune();
|
|
5059
|
+
this.changed();
|
|
5060
|
+
} else if (m.t === "mic") {
|
|
5061
|
+
const peer = this.peersById.get(m.id);
|
|
5062
|
+
if (peer) peer.micOn = m.on;
|
|
5063
|
+
this.changed();
|
|
5064
|
+
} else if (m.t === "signal") {
|
|
5065
|
+
void this.onSignal(m.from, m.signal);
|
|
5066
|
+
}
|
|
5067
|
+
}
|
|
5068
|
+
addPeer(id, caller, micOn) {
|
|
5069
|
+
const existing = this.peersById.get(id);
|
|
5070
|
+
if (existing) return existing;
|
|
5071
|
+
const pc = this.deps.createConnection(this.config);
|
|
5072
|
+
const peer = {
|
|
5073
|
+
id,
|
|
5074
|
+
pc,
|
|
5075
|
+
caller,
|
|
5076
|
+
audio: null,
|
|
5077
|
+
pendingIce: [],
|
|
5078
|
+
player: null,
|
|
5079
|
+
meter: null,
|
|
5080
|
+
connected: false,
|
|
5081
|
+
micOn,
|
|
5082
|
+
muted: false,
|
|
5083
|
+
volume: 1,
|
|
5084
|
+
talking: false,
|
|
5085
|
+
restarts: 0
|
|
5086
|
+
};
|
|
5087
|
+
this.peersById.set(id, peer);
|
|
5088
|
+
pc.ontrack = (event) => {
|
|
5089
|
+
if (event.track.kind === "audio") this.hear(peer, event.track);
|
|
5090
|
+
};
|
|
5091
|
+
pc.onicecandidate = (event) => {
|
|
5092
|
+
if (event.candidate) this.signal(id, { kind: "ice", candidate: event.candidate.toJSON() });
|
|
5093
|
+
};
|
|
5094
|
+
pc.onconnectionstatechange = () => this.connectionChanged(peer);
|
|
5095
|
+
return peer;
|
|
5096
|
+
}
|
|
5097
|
+
signal(to, signal) {
|
|
5098
|
+
this.transport.sendVoice({ t: "signal", to, signal });
|
|
5099
|
+
}
|
|
5100
|
+
hear(peer, track) {
|
|
5101
|
+
peer.player?.stop();
|
|
5102
|
+
peer.meter?.close();
|
|
5103
|
+
peer.player = this.deps.playRemote(track);
|
|
5104
|
+
peer.player.setVolume(peer.muted ? 0 : peer.volume);
|
|
5105
|
+
peer.meter = this.deps.analyse(track);
|
|
5106
|
+
}
|
|
5107
|
+
connectionChanged(peer) {
|
|
5108
|
+
if (this.peersById.get(peer.id) !== peer) return;
|
|
5109
|
+
const state = peer.pc.connectionState;
|
|
5110
|
+
if (state === "connected") {
|
|
5111
|
+
peer.connected = true;
|
|
5112
|
+
peer.restarts = 0;
|
|
5113
|
+
void this.applyBitrate(peer);
|
|
5114
|
+
this.changed();
|
|
5115
|
+
} else if (state === "failed") {
|
|
5116
|
+
peer.connected = false;
|
|
5117
|
+
this.changed();
|
|
5118
|
+
if (peer.caller && peer.restarts < MAX_RESTARTS) {
|
|
5119
|
+
peer.restarts += 1;
|
|
5120
|
+
void this.offer(peer, true);
|
|
5121
|
+
}
|
|
5122
|
+
}
|
|
5123
|
+
}
|
|
5124
|
+
async call(peer) {
|
|
5125
|
+
peer.audio = peer.pc.addTransceiver("audio", { direction: "sendrecv" });
|
|
5126
|
+
await this.sendMic(peer);
|
|
5127
|
+
await this.offer(peer, false);
|
|
5128
|
+
}
|
|
5129
|
+
async offer(peer, restart) {
|
|
5130
|
+
try {
|
|
5131
|
+
if (restart) peer.pc.restartIce();
|
|
5132
|
+
await peer.pc.setLocalDescription(await peer.pc.createOffer({ iceRestart: restart }));
|
|
5133
|
+
const sdp = peer.pc.localDescription?.sdp;
|
|
5134
|
+
if (sdp) this.signal(peer.id, { kind: "offer", sdp });
|
|
5135
|
+
} catch (error) {
|
|
5136
|
+
console.warn("[relu] Couldn't start a voice connection.", error);
|
|
5137
|
+
}
|
|
5138
|
+
}
|
|
5139
|
+
async onSignal(from, signal) {
|
|
5140
|
+
const peer = this.addPeer(from, false, true);
|
|
5141
|
+
try {
|
|
5142
|
+
if (signal.kind === "ice") {
|
|
5143
|
+
if (peer.pc.remoteDescription) await peer.pc.addIceCandidate(signal.candidate);
|
|
5144
|
+
else peer.pendingIce.push(signal.candidate);
|
|
5145
|
+
return;
|
|
5146
|
+
}
|
|
5147
|
+
await peer.pc.setRemoteDescription({ type: signal.kind, sdp: tuneOpus(signal.sdp) });
|
|
5148
|
+
for (const candidate of peer.pendingIce.splice(0)) {
|
|
5149
|
+
await peer.pc.addIceCandidate(candidate);
|
|
5150
|
+
}
|
|
5151
|
+
if (signal.kind === "answer") return;
|
|
5152
|
+
if (!peer.audio) {
|
|
5153
|
+
const slot = peer.pc.getTransceivers().find((t) => t.receiver.track.kind === "audio");
|
|
5154
|
+
if (slot) {
|
|
5155
|
+
slot.direction = "sendrecv";
|
|
5156
|
+
peer.audio = slot;
|
|
5157
|
+
}
|
|
5158
|
+
}
|
|
5159
|
+
await this.sendMic(peer);
|
|
5160
|
+
await peer.pc.setLocalDescription(await peer.pc.createAnswer());
|
|
5161
|
+
const sdp = peer.pc.localDescription?.sdp;
|
|
5162
|
+
if (sdp) this.signal(from, { kind: "answer", sdp });
|
|
5163
|
+
} catch (error) {
|
|
5164
|
+
console.warn("[relu] A voice connection failed to set up.", error);
|
|
5165
|
+
}
|
|
5166
|
+
}
|
|
5167
|
+
async sendMic(peer) {
|
|
5168
|
+
const sender = peer.audio?.sender;
|
|
5169
|
+
if (!sender) return;
|
|
5170
|
+
await sender.replaceTrack(this.micMuted ? null : this.track).catch(() => void 0);
|
|
5171
|
+
await this.applyBitrate(peer);
|
|
5172
|
+
}
|
|
5173
|
+
sendMicToAll() {
|
|
5174
|
+
for (const peer of this.peersById.values()) void this.sendMic(peer);
|
|
5175
|
+
}
|
|
5176
|
+
async applyBitrate(peer) {
|
|
5177
|
+
const sender = peer.audio?.sender;
|
|
5178
|
+
if (!sender) return;
|
|
5179
|
+
try {
|
|
5180
|
+
const parameters = sender.getParameters();
|
|
5181
|
+
if (!parameters.encodings?.length) parameters.encodings = [{}];
|
|
5182
|
+
parameters.encodings[0].maxBitrate = LEVEL_BPS[this.level];
|
|
5183
|
+
await sender.setParameters(parameters);
|
|
5184
|
+
} catch {
|
|
5185
|
+
}
|
|
5186
|
+
}
|
|
5187
|
+
setLevel(level) {
|
|
5188
|
+
if (level === this.level) return;
|
|
5189
|
+
this.level = level;
|
|
5190
|
+
for (const peer of this.peersById.values()) void this.applyBitrate(peer);
|
|
5191
|
+
}
|
|
5192
|
+
/** Someone came or went: the rate each connection gets changes with it. */
|
|
5193
|
+
retune() {
|
|
5194
|
+
this.setLevel(Math.max(this.level, startingLevel(this.peersById.size)));
|
|
5195
|
+
}
|
|
5196
|
+
closePeer(peer) {
|
|
5197
|
+
peer.player?.stop();
|
|
5198
|
+
peer.meter?.close();
|
|
5199
|
+
peer.pc.ontrack = null;
|
|
5200
|
+
peer.pc.onicecandidate = null;
|
|
5201
|
+
peer.pc.onconnectionstatechange = null;
|
|
5202
|
+
peer.pc.close();
|
|
5203
|
+
this.peersById.delete(peer.id);
|
|
5204
|
+
}
|
|
5205
|
+
startTimers() {
|
|
5206
|
+
const stopPoll = this.deps.every(POLL_MS, () => this.poll());
|
|
5207
|
+
const stopTick = this.deps.every(TICK_MS, () => void this.tick());
|
|
5208
|
+
this.stopTimers = () => {
|
|
5209
|
+
stopPoll();
|
|
5210
|
+
stopTick();
|
|
5211
|
+
};
|
|
5212
|
+
}
|
|
5213
|
+
/** Who's talking, where they are, and the music under them. */
|
|
5214
|
+
poll() {
|
|
5215
|
+
let anyone = false;
|
|
5216
|
+
let moved2 = false;
|
|
5217
|
+
for (const peer of this.peersById.values()) {
|
|
5218
|
+
const talking = !peer.muted && (peer.meter?.level() ?? 0) > TALKING_LEVEL;
|
|
5219
|
+
if (talking !== peer.talking) moved2 = true;
|
|
5220
|
+
peer.talking = talking;
|
|
5221
|
+
anyone || (anyone = talking);
|
|
5222
|
+
peer.player?.setPosition(this.positional ? this.deps.positionOf(peer.id) : null);
|
|
5223
|
+
}
|
|
5224
|
+
const me = !this.micMuted && (this.meter?.level() ?? 0) > TALKING_LEVEL;
|
|
5225
|
+
if (me !== this.talking) moved2 = true;
|
|
5226
|
+
this.talking = me;
|
|
5227
|
+
if (anyone) this.deps.duck(DUCK_SECONDS);
|
|
5228
|
+
if (moved2) this.changed();
|
|
5229
|
+
}
|
|
5230
|
+
/** Step quality to what the connections can carry. */
|
|
5231
|
+
async tick() {
|
|
5232
|
+
let bad = false;
|
|
5233
|
+
for (const peer of this.peersById.values()) {
|
|
5234
|
+
if (!peer.connected) continue;
|
|
5235
|
+
const stats = await readLinkStats(peer.pc).catch(() => null);
|
|
5236
|
+
if (stats && (stats.loss > MAX_LOSS || stats.rttMs > MAX_RTT_MS)) bad = true;
|
|
5237
|
+
}
|
|
5238
|
+
if (this.state !== "on") return;
|
|
5239
|
+
const step = adaptLevel(this.level, startingLevel(this.peersById.size), bad, this.goodMs);
|
|
5240
|
+
this.goodMs = step.goodMs;
|
|
5241
|
+
this.setLevel(step.level);
|
|
5242
|
+
}
|
|
5243
|
+
/** The room is going: stop everything. There's no one left to tell. */
|
|
5244
|
+
dispose() {
|
|
5245
|
+
this.disposed = true;
|
|
5246
|
+
this.shutDown();
|
|
5247
|
+
this.available = false;
|
|
5248
|
+
this.changed();
|
|
5249
|
+
this.listeners.clear();
|
|
5250
|
+
}
|
|
5251
|
+
};
|
|
5252
|
+
__name(_VoiceChat, "VoiceChat");
|
|
5253
|
+
var VoiceChat = _VoiceChat;
|
|
5254
|
+
|
|
5255
|
+
// core/network/voiceBrowser.ts
|
|
5256
|
+
var FULL_VOLUME_DISTANCE = 2;
|
|
5257
|
+
var SILENT_DISTANCE = 60;
|
|
5258
|
+
var POSITION_SMOOTHING = 0.05;
|
|
5259
|
+
function voiceGraph(mixer, stream) {
|
|
5260
|
+
const context = mixer?.listener.context;
|
|
5261
|
+
if (!mixer || !context || context.state !== "running") return null;
|
|
5262
|
+
const bus = mixer.input("voice");
|
|
5263
|
+
const source = context.createMediaStreamSource(stream);
|
|
5264
|
+
const gain = context.createGain();
|
|
5265
|
+
const panner = context.createPanner();
|
|
5266
|
+
panner.panningModel = "equalpower";
|
|
5267
|
+
panner.distanceModel = "inverse";
|
|
5268
|
+
panner.refDistance = FULL_VOLUME_DISTANCE;
|
|
5269
|
+
panner.maxDistance = SILENT_DISTANCE;
|
|
5270
|
+
source.connect(gain);
|
|
5271
|
+
gain.connect(bus);
|
|
5272
|
+
let placed = false;
|
|
5273
|
+
return {
|
|
5274
|
+
setVolume(volume) {
|
|
5275
|
+
gain.gain.setTargetAtTime(Math.max(0, Math.min(2, volume)), context.currentTime, 0.02);
|
|
5276
|
+
},
|
|
5277
|
+
setPosition(position) {
|
|
5278
|
+
if (Boolean(position) !== placed) {
|
|
5279
|
+
placed = !placed;
|
|
5280
|
+
gain.disconnect();
|
|
5281
|
+
panner.disconnect();
|
|
5282
|
+
if (placed) {
|
|
5283
|
+
gain.connect(panner);
|
|
5284
|
+
panner.connect(bus);
|
|
5285
|
+
} else {
|
|
5286
|
+
gain.connect(bus);
|
|
5287
|
+
}
|
|
5288
|
+
}
|
|
5289
|
+
if (!position) return;
|
|
5290
|
+
const now = context.currentTime;
|
|
5291
|
+
panner.positionX.setTargetAtTime(position.x, now, POSITION_SMOOTHING);
|
|
5292
|
+
panner.positionY.setTargetAtTime(position.y, now, POSITION_SMOOTHING);
|
|
5293
|
+
panner.positionZ.setTargetAtTime(position.z, now, POSITION_SMOOTHING);
|
|
5294
|
+
},
|
|
5295
|
+
stop() {
|
|
5296
|
+
source.disconnect();
|
|
5297
|
+
gain.disconnect();
|
|
5298
|
+
panner.disconnect();
|
|
5299
|
+
}
|
|
5300
|
+
};
|
|
5301
|
+
}
|
|
5302
|
+
__name(voiceGraph, "voiceGraph");
|
|
5303
|
+
function playRemote(mixer, track) {
|
|
5304
|
+
const stream = new MediaStream([track]);
|
|
5305
|
+
const element = document.createElement("audio");
|
|
5306
|
+
element.autoplay = true;
|
|
5307
|
+
element.setAttribute("playsinline", "");
|
|
5308
|
+
element.srcObject = stream;
|
|
5309
|
+
element.style.display = "none";
|
|
5310
|
+
const graph = voiceGraph(mixer, stream);
|
|
5311
|
+
element.muted = graph !== null;
|
|
5312
|
+
document.body.appendChild(element);
|
|
5313
|
+
void element.play().catch(() => void 0);
|
|
5314
|
+
return {
|
|
5315
|
+
setVolume(volume) {
|
|
5316
|
+
if (graph) graph.setVolume(volume);
|
|
5317
|
+
else element.volume = Math.max(0, Math.min(1, volume));
|
|
5318
|
+
},
|
|
5319
|
+
setPosition(position) {
|
|
5320
|
+
graph?.setPosition(position);
|
|
5321
|
+
},
|
|
5322
|
+
stop() {
|
|
5323
|
+
graph?.stop();
|
|
5324
|
+
element.pause();
|
|
5325
|
+
element.srcObject = null;
|
|
5326
|
+
element.remove();
|
|
5327
|
+
}
|
|
5328
|
+
};
|
|
5329
|
+
}
|
|
5330
|
+
__name(playRemote, "playRemote");
|
|
5331
|
+
function analyse(mixer, from) {
|
|
5332
|
+
const context = mixer?.listener.context;
|
|
5333
|
+
if (!context) return null;
|
|
5334
|
+
const stream = from instanceof MediaStream ? from : new MediaStream([from]);
|
|
5335
|
+
const source = context.createMediaStreamSource(stream);
|
|
5336
|
+
const analyser = context.createAnalyser();
|
|
5337
|
+
analyser.fftSize = 512;
|
|
5338
|
+
source.connect(analyser);
|
|
5339
|
+
const samples = new Float32Array(analyser.fftSize);
|
|
5340
|
+
return {
|
|
5341
|
+
level() {
|
|
5342
|
+
analyser.getFloatTimeDomainData(samples);
|
|
5343
|
+
let sum = 0;
|
|
5344
|
+
for (const sample of samples) sum += sample * sample;
|
|
5345
|
+
return Math.min(1, Math.sqrt(sum / samples.length) * 3);
|
|
5346
|
+
},
|
|
5347
|
+
close() {
|
|
5348
|
+
source.disconnect();
|
|
5349
|
+
analyser.disconnect();
|
|
5350
|
+
}
|
|
5351
|
+
};
|
|
5352
|
+
}
|
|
5353
|
+
__name(analyse, "analyse");
|
|
5354
|
+
function browserVoiceDeps(world, positionOf) {
|
|
5355
|
+
const mixer = /* @__PURE__ */ __name(() => world.getAudioSystem()?.getMixer() ?? null, "mixer");
|
|
5356
|
+
return {
|
|
5357
|
+
supported: /* @__PURE__ */ __name(() => typeof RTCPeerConnection !== "undefined" && globalThis.isSecureContext && Boolean(navigator.mediaDevices?.getUserMedia), "supported"),
|
|
5358
|
+
createConnection: /* @__PURE__ */ __name((config) => new RTCPeerConnection(config), "createConnection"),
|
|
5359
|
+
getMic: /* @__PURE__ */ __name((constraints) => navigator.mediaDevices.getUserMedia(constraints), "getMic"),
|
|
5360
|
+
setCapture(on) {
|
|
5361
|
+
const session = navigator.audioSession;
|
|
5362
|
+
if (session) session.type = on ? "play-and-record" : "playback";
|
|
5363
|
+
if (on) void mixer()?.listener.context.resume();
|
|
5364
|
+
},
|
|
5365
|
+
analyse: /* @__PURE__ */ __name((from) => analyse(mixer(), from), "analyse"),
|
|
5366
|
+
playRemote: /* @__PURE__ */ __name((track) => playRemote(mixer(), track), "playRemote"),
|
|
5367
|
+
duck: /* @__PURE__ */ __name((seconds) => mixer()?.duckMusic(seconds), "duck"),
|
|
5368
|
+
positionOf,
|
|
5369
|
+
every(ms, fn) {
|
|
5370
|
+
const timer = setInterval(fn, ms);
|
|
5371
|
+
return () => clearInterval(timer);
|
|
5372
|
+
}
|
|
5373
|
+
};
|
|
5374
|
+
}
|
|
5375
|
+
__name(browserVoiceDeps, "browserVoiceDeps");
|
|
5376
|
+
|
|
4748
5377
|
// core/network/NetworkSystem.ts
|
|
4749
5378
|
var POSITION_EPSILON = 1e-3;
|
|
4750
5379
|
var ROTATION_EPSILON = 5e-4;
|
|
@@ -4784,9 +5413,14 @@ var _NetworkSystem = class _NetworkSystem {
|
|
|
4784
5413
|
this.listeners = /* @__PURE__ */ new Map();
|
|
4785
5414
|
this.handles = /* @__PURE__ */ new Map();
|
|
4786
5415
|
this.spawnedAvatar = false;
|
|
5416
|
+
/** Voice chat, once connected with it on. */
|
|
5417
|
+
this.voice = null;
|
|
4787
5418
|
}
|
|
4788
|
-
/**
|
|
4789
|
-
|
|
5419
|
+
/**
|
|
5420
|
+
* Join the shared world. Call after hydrating, so scene objects exist.
|
|
5421
|
+
* `voice: true` adds voice chat; the room still decides whether it's offered.
|
|
5422
|
+
*/
|
|
5423
|
+
connect(world, transport, options = {}) {
|
|
4790
5424
|
this.world = world;
|
|
4791
5425
|
this.transport = transport;
|
|
4792
5426
|
world.setNetwork(this);
|
|
@@ -4799,7 +5433,14 @@ var _NetworkSystem = class _NetworkSystem {
|
|
|
4799
5433
|
for (const fn of this.listeners.get(name) ?? []) fn(payload, from);
|
|
4800
5434
|
});
|
|
4801
5435
|
transport.onCorrect((id, pose) => this.corrected(id, pose));
|
|
5436
|
+
transport.onSound((sound) => this.heardSound(sound));
|
|
4802
5437
|
transport.onClose(() => this.disconnected());
|
|
5438
|
+
if (options.voice) {
|
|
5439
|
+
this.voice = new VoiceChat(
|
|
5440
|
+
transport,
|
|
5441
|
+
options.voice === true ? browserVoiceDeps(world, (id) => this.positionOf(id)) : options.voice
|
|
5442
|
+
);
|
|
5443
|
+
}
|
|
4803
5444
|
this.registerScene();
|
|
4804
5445
|
this.spawnAvatar();
|
|
4805
5446
|
}
|
|
@@ -4843,11 +5484,12 @@ var _NetworkSystem = class _NetworkSystem {
|
|
|
4843
5484
|
}
|
|
4844
5485
|
});
|
|
4845
5486
|
}
|
|
4846
|
-
track(id, entityId, owner, view2) {
|
|
5487
|
+
track(id, entityId, owner, view2, prefab = "") {
|
|
4847
5488
|
const entry = {
|
|
4848
5489
|
id,
|
|
4849
5490
|
entityId,
|
|
4850
5491
|
owner,
|
|
5492
|
+
prefab,
|
|
4851
5493
|
buffer: new SnapshotBuffer(),
|
|
4852
5494
|
sentAt: 0,
|
|
4853
5495
|
sent: null,
|
|
@@ -4880,7 +5522,7 @@ var _NetworkSystem = class _NetworkSystem {
|
|
|
4880
5522
|
this.entityChanged(view2);
|
|
4881
5523
|
return;
|
|
4882
5524
|
}
|
|
4883
|
-
const entry = this.track(view2.id, null, view2.owner, view2);
|
|
5525
|
+
const entry = this.track(view2.id, null, view2.owner, view2, view2.prefab);
|
|
4884
5526
|
if (!view2.prefab || !this.world) return;
|
|
4885
5527
|
euler.setFromQuaternion(quat.set(view2.qx, view2.qy, view2.qz, view2.qw));
|
|
4886
5528
|
void spawnPrefab(this.world, view2.prefab, {
|
|
@@ -4937,7 +5579,23 @@ var _NetworkSystem = class _NetworkSystem {
|
|
|
4937
5579
|
if (transform) applyPose(transform, pose, true);
|
|
4938
5580
|
entry.sent = pose;
|
|
4939
5581
|
}
|
|
5582
|
+
heardSound(sound) {
|
|
5583
|
+
const { id, volume, x, y, z } = sound;
|
|
5584
|
+
const placed = x !== void 0 && y !== void 0 && z !== void 0;
|
|
5585
|
+
this.world?.getAudioSystem()?.playSlot(id, { volume, position: placed ? { x, y, z } : void 0 });
|
|
5586
|
+
}
|
|
5587
|
+
/** Where a player's character is: the first thing they spawned, which is their avatar. */
|
|
5588
|
+
positionOf(peerId) {
|
|
5589
|
+
for (const entry of this.tracked.values()) {
|
|
5590
|
+
if (entry.owner !== peerId || !entry.prefab || entry.entityId === null) continue;
|
|
5591
|
+
const position = this.world?.getComponent(entry.entityId, TransformComponent)?.position;
|
|
5592
|
+
return position ? { x: position.x, y: position.y, z: position.z } : null;
|
|
5593
|
+
}
|
|
5594
|
+
return null;
|
|
5595
|
+
}
|
|
4940
5596
|
disconnected() {
|
|
5597
|
+
this.voice?.dispose();
|
|
5598
|
+
this.voice = null;
|
|
4941
5599
|
this.transport = null;
|
|
4942
5600
|
this.world?.setNetwork(null);
|
|
4943
5601
|
}
|
|
@@ -4981,6 +5639,8 @@ var _NetworkSystem = class _NetworkSystem {
|
|
|
4981
5639
|
}
|
|
4982
5640
|
}
|
|
4983
5641
|
dispose() {
|
|
5642
|
+
this.voice?.dispose();
|
|
5643
|
+
this.voice = null;
|
|
4984
5644
|
this.transport?.leave();
|
|
4985
5645
|
this.transport = null;
|
|
4986
5646
|
this.world?.setNetwork(null);
|
|
@@ -5027,7 +5687,22 @@ var _NetworkSystem = class _NetworkSystem {
|
|
|
5027
5687
|
get players() {
|
|
5028
5688
|
return self.transport?.players ?? [];
|
|
5029
5689
|
},
|
|
5690
|
+
get voice() {
|
|
5691
|
+
return self.voice;
|
|
5692
|
+
},
|
|
5030
5693
|
send: /* @__PURE__ */ __name((name, payload) => self.transport?.send(name, payload ?? null), "send"),
|
|
5694
|
+
playSlot: /* @__PURE__ */ __name((id, options = {}) => {
|
|
5695
|
+
const played = self.world?.getAudioSystem()?.playSlot(id, options) ?? false;
|
|
5696
|
+
if (played) {
|
|
5697
|
+
const { volume, position } = options;
|
|
5698
|
+
self.transport?.sendSound({
|
|
5699
|
+
id,
|
|
5700
|
+
...volume === void 0 ? {} : { volume },
|
|
5701
|
+
...position ? { x: position.x, y: position.y, z: position.z } : {}
|
|
5702
|
+
});
|
|
5703
|
+
}
|
|
5704
|
+
return played;
|
|
5705
|
+
}, "playSlot"),
|
|
5031
5706
|
onMessage: /* @__PURE__ */ __name((name, fn) => {
|
|
5032
5707
|
const set = self.listeners.get(name) ?? /* @__PURE__ */ new Set();
|
|
5033
5708
|
self.listeners.set(name, set);
|
|
@@ -5064,6 +5739,8 @@ var NetworkSystem = _NetworkSystem;
|
|
|
5064
5739
|
exports.ColyseusTransport = ColyseusTransport;
|
|
5065
5740
|
exports.NetworkSystem = NetworkSystem;
|
|
5066
5741
|
exports.SnapshotBuffer = SnapshotBuffer;
|
|
5742
|
+
exports.VoiceChat = VoiceChat;
|
|
5743
|
+
exports.browserVoiceDeps = browserVoiceDeps;
|
|
5067
5744
|
exports.withLatency = withLatency;
|
|
5068
5745
|
//# sourceMappingURL=index.cjs.map
|
|
5069
5746
|
//# sourceMappingURL=index.cjs.map
|