openrtc-netcode 3.0.0 → 3.0.2

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/voice.js CHANGED
@@ -11,6 +11,8 @@ export class NetcodeVoiceController {
11
11
  this.localMediaStream = null;
12
12
  this.options = {};
13
13
  this.active = false;
14
+ this.generation = 0;
15
+ this.starting = null;
14
16
  this.muted = false;
15
17
  }
16
18
  get enabled() {
@@ -30,41 +32,61 @@ export class NetcodeVoiceController {
30
32
  }));
31
33
  }
32
34
  async start(options = {}) {
35
+ if (this.starting)
36
+ return this.starting;
33
37
  if (this.active && this.localMediaStream)
34
38
  return this.localMediaStream;
35
- this.options = options;
36
- const localStream = options.mediaStream ?? await this.captureAudio(options);
37
- this.localMediaStream = localStream;
38
- this.active = true;
39
- this.applyMuteState();
40
- this.emitter.emit('local:stream', localStream);
41
- const localPeerId = this.localPeerId();
42
- if (localPeerId) {
39
+ const generation = ++this.generation;
40
+ const opening = (async () => {
41
+ const localStream = options.mediaStream ?? await this.captureAudio(options);
42
+ if (generation !== this.generation) {
43
+ if (!options.mediaStream || options.stopTracksOnStop !== false) {
44
+ for (const track of localStream.getTracks())
45
+ track.stop();
46
+ }
47
+ throw new Error('Voice start was superseded by stop.');
48
+ }
49
+ this.options = options;
50
+ this.localMediaStream = localStream;
51
+ this.active = true;
52
+ this.applyMuteState();
53
+ this.emitter.emit('local:stream', localStream);
43
54
  try {
44
- await Promise.all(this.peers()
45
- .filter((peer) => peer.connected)
46
- .map((peer) => this.startPeerSession(peer.id)));
55
+ if (this.localPeerId())
56
+ await Promise.all(this.peers()
57
+ .filter((peer) => peer.connected).map((peer) => this.startPeerSession(peer.id)));
47
58
  }
48
59
  catch (error) {
49
- await this.stop();
60
+ if (generation === this.generation)
61
+ await this.stop();
50
62
  throw error;
51
63
  }
52
- }
53
- if (!this.active || this.localMediaStream !== localStream) {
54
- throw new Error('Voice start was superseded by stop.');
55
- }
56
- return localStream;
64
+ if (generation !== this.generation)
65
+ throw new Error('Voice start was superseded by stop.');
66
+ return localStream;
67
+ })().finally(() => { if (this.starting === opening)
68
+ this.starting = null; });
69
+ this.starting = opening;
70
+ return opening;
57
71
  }
58
72
  async stop() {
73
+ this.generation++;
74
+ this.starting = null;
59
75
  this.active = false;
76
+ const localStream = this.localMediaStream;
77
+ const stopTracks = this.options.stopTracksOnStop !== false;
78
+ this.localMediaStream = null;
79
+ const generation = this.generation;
60
80
  const current = [...this.sessions.keys()].map((peerId) => this.closePeerSession(peerId));
61
81
  await Promise.allSettled([...current, ...this.closingSessions]);
62
- if (this.options.stopTracksOnStop !== false) {
63
- for (const track of this.localMediaStream?.getTracks() ?? [])
64
- track.stop();
65
- }
66
- this.localMediaStream = null;
67
- this.emitter.emit('peers', []);
82
+ const retainedTracks = new Set(this.localStream?.getTracks() ?? []);
83
+ if (stopTracks)
84
+ for (const track of localStream?.getTracks() ?? []) {
85
+ if (!retainedTracks.has(track))
86
+ track.stop();
87
+ }
88
+ if (generation === this.generation)
89
+ this.emitter.emit('peers', []);
68
90
  }
69
91
  setMuted(muted) {
70
92
  this.muted = muted;
@@ -79,18 +101,24 @@ export class NetcodeVoiceController {
79
101
  await this.startPeerSession(peerId);
80
102
  }
81
103
  handlePeerDisconnected(peerId) {
82
- this.trackClosing(this.closePeerSession(peerId));
104
+ void this.closePeerSession(peerId);
83
105
  }
84
106
  async startPeerSession(peerId) {
85
107
  const connection = this.connectionForPeer(peerId);
86
108
  const localStream = this.localMediaStream;
87
- if (!connection || !localStream)
109
+ if (!connection || !localStream || !this.active || Boolean(connection.isClosed))
88
110
  return;
89
- const existing = this.sessions.get(peerId);
90
- if (existing?.connection === connection)
91
- return;
92
- if (existing)
111
+ while (true) {
112
+ if (!this.active || this.localMediaStream !== localStream || this.connectionForPeer(peerId) !== connection || Boolean(connection.isClosed))
113
+ return;
114
+ const existing = this.sessions.get(peerId);
115
+ if (existing?.connection === connection)
116
+ return;
117
+ if (!existing)
118
+ break;
93
119
  await this.closePeerSession(peerId, false);
120
+ // Another notification may have installed its session while removal awaited.
121
+ }
94
122
  const session = {
95
123
  peerId,
96
124
  connection,
@@ -98,20 +126,30 @@ export class NetcodeVoiceController {
98
126
  remoteStream: null,
99
127
  stopListening: () => undefined,
100
128
  };
129
+ this.sessions.set(peerId, session);
101
130
  session.stopListening = connection.media.onTrack((event) => {
102
- session.remoteStream = event.streams[0] ?? null;
131
+ if (!this.active || this.sessions.get(peerId) !== session || event.track.kind !== 'audio')
132
+ return;
133
+ session.remoteStream = event.streams[0] ?? (typeof MediaStream !== 'undefined' ? new MediaStream([event.track]) : null);
103
134
  if (session.remoteStream) {
104
135
  this.emitter.emit('peer:stream', { peerId, stream: session.remoteStream });
105
136
  }
106
137
  this.emitter.emit('peers', this.listPeers());
107
138
  });
108
- this.sessions.set(peerId, session);
139
+ // Publication can precede local microphone enable. Subscribe first, then
140
+ // project the media owner's existing receivers rather than losing that track.
141
+ const tracks = [...new Set(connection.media.receivers?.().map((receiver) => receiver.track)
142
+ .filter((track) => track.kind === 'audio' && track.readyState === 'live') ?? [])];
143
+ if (!session.remoteStream && tracks.length && typeof MediaStream !== 'undefined') {
144
+ session.remoteStream = new MediaStream(tracks);
145
+ this.emitter.emit('peer:stream', { peerId, stream: session.remoteStream });
146
+ }
109
147
  this.emitter.emit('peers', this.listPeers());
110
148
  try {
111
149
  await Promise.resolve();
112
150
  for (const track of localStream.getAudioTracks()) {
113
151
  const sender = await connection.media.addTrack(track, { streams: [localStream] });
114
- if (this.sessions.get(peerId) !== session || !this.active || connection.isClosed === true) {
152
+ if (this.sessions.get(peerId) !== session || !this.active || Boolean(connection.isClosed)) {
115
153
  await connection.media.removeTrack(sender);
116
154
  return;
117
155
  }
@@ -128,17 +166,21 @@ export class NetcodeVoiceController {
128
166
  throw error;
129
167
  }
130
168
  }
131
- async closePeerSession(peerId, emit = true) {
169
+ closePeerSession(peerId, emit = true) {
132
170
  const session = this.sessions.get(peerId);
133
171
  if (!session)
134
- return;
172
+ return Promise.resolve();
135
173
  this.sessions.delete(peerId);
136
174
  session.stopListening();
137
- await Promise.allSettled(session.senders.map((sender) => session.connection.media.removeTrack(sender)));
138
- if (emit) {
139
- this.emitter.emit('peer:left', { peerId });
140
- this.emitter.emit('peers', this.listPeers());
141
- }
175
+ const closing = Promise.allSettled(session.senders.map((sender) => session.connection.media.removeTrack(sender))).then(() => {
176
+ if (emit) {
177
+ if (!this.sessions.has(peerId))
178
+ this.emitter.emit('peer:left', { peerId });
179
+ this.emitter.emit('peers', this.listPeers());
180
+ }
181
+ });
182
+ this.trackClosing(closing);
183
+ return closing;
142
184
  }
143
185
  trackClosing(operation) {
144
186
  this.closingSessions.add(operation);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openrtc-netcode",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -42,7 +42,7 @@
42
42
  "test:watch": "vitest"
43
43
  },
44
44
  "dependencies": {
45
- "openrtc": "^3.0.0"
45
+ "openrtc": "^3.0.2"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^24.10.1",