vimp-engine 0.38.3 → 0.40.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/README.md +2 -2
- package/bin/vimp-sim.js +2 -2
- package/core/Cargo.toml +2 -2
- package/package.json +3 -4
- package/src/client/InputListener.js +3 -0
- package/src/client/SoundManager.js +13 -0
- package/src/client/boot.js +11 -87
- package/src/client/components/controller/Controls.js +10 -0
- package/src/client/components/controller/Voice.js +31 -0
- package/src/client/components/model/Controls.js +64 -0
- package/src/client/components/model/RoomMenu.js +1 -1
- package/src/client/components/model/Voice.js +96 -0
- package/src/client/components/model/Vote.js +2 -1
- package/src/client/components/view/Auth.js +3 -3
- package/src/client/components/view/Voice.js +172 -0
- package/src/client/lib/formBuilder.js +7 -6
- package/src/client/lib/hostVoteCommand.js +2 -2
- package/src/client/lib/pickActiveGame.js +3 -12
- package/src/client/lib/retryOnNetworkError.js +0 -9
- package/src/client/lib/voiceState.js +78 -0
- package/src/client/main.js +160 -90
- package/src/client/network/HostConnectionManager.js +46 -5
- package/src/client/network/HostController.js +43 -0
- package/src/client/network/LoopbackTransport.js +14 -0
- package/src/client/network/SessionSupervisor.js +38 -14
- package/src/client/network/WebRtcManager.js +46 -5
- package/src/client/network/policyClose.js +10 -77
- package/src/client/style.css +150 -9
- package/src/client/views/gameShell.js +2 -4
- package/src/client/views/includes/auth.pug +1 -4
- package/src/client/views/includes/lobby.pug +3 -6
- package/src/client/voice/JitterBuffer.js +137 -0
- package/src/client/voice/MicCapture.js +307 -0
- package/src/client/voice/PttMachine.js +369 -0
- package/src/client/voice/VoiceClient.js +336 -0
- package/src/client/voice/VoiceCues.js +295 -0
- package/src/client/voice/VoiceEncoder.js +174 -0
- package/src/client/voice/VoicePlayback.js +558 -0
- package/src/client/voice/audioData.js +35 -0
- package/src/client/voice/captureProcessor.worklet.js +136 -0
- package/src/client/voice/opusConfig.js +49 -0
- package/src/client/voice/voiceOutput.js +72 -0
- package/src/client/voice/voiceSupport.js +71 -0
- package/src/client/voice/voiceTones.js +90 -0
- package/src/config/clientServices.js +2 -0
- package/src/config/closeCodes.js +7 -9
- package/src/config/gameCodes.js +1 -1
- package/src/config/hostDefaults.js +2 -2
- package/src/config/lobby.js +3 -4
- package/src/config/voiceDefaults.js +44 -0
- package/src/config/voiceProtocol.js +80 -0
- package/src/config/voteKeys.js +4 -0
- package/src/config/wsports.js +13 -1
- package/src/devtools/ScenarioRunner.js +1 -1
- package/src/devtools/builtinScenario.js +2 -2
- package/src/devtools/contract/rules/a2-package-scripts.js +1 -1
- package/src/devtools/contract/rules/a4-vite-config.js +1 -1
- package/src/devtools/contract/rules/b11-voice-config.js +34 -0
- package/src/devtools/contract/rules/c12-voice-hotkey.js +70 -0
- package/src/devtools/contract/rules/c4-component-dependencies.js +1 -1
- package/src/devtools/contract/rules/index.js +4 -0
- package/src/devtools/invariants.js +1 -1
- package/src/devtools/pluginLoader.js +2 -2
- package/src/host/HostGame.js +62 -4
- package/src/host/PortMachine.js +23 -24
- package/src/host/commandFailure.js +2 -2
- package/src/host/host.worker.js +7 -0
- package/src/host/identity.js +3 -3
- package/src/host/meta/SocketManager.js +6 -0
- package/src/host/meta/core/CommandProcessor.js +2 -2
- package/src/host/meta/modules/Accolades.js +1 -1
- package/src/host/meta/modules/voice/Voice.js +430 -0
- package/src/host/meta/modules/voice/channels.js +58 -0
- package/src/host/meta/modules/voice/index.js +1 -0
- package/src/lib/VoiceRelay.js +200 -0
- package/src/lib/buildClientConfig.js +7 -0
- package/src/lib/capabilities.js +4 -0
- package/src/lib/createHostRuntime.js +8 -0
- package/src/lib/gameConfigView.js +11 -1
- package/src/lib/loadGamePackage.js +7 -9
- package/src/lib/offlinePlayerData.js +1 -1
- package/src/lib/validators.js +2 -2
- package/src/lib/voiceConfig.js +136 -0
- package/src/lib/voiceMessages.js +172 -0
- package/src/lib/voicePacket.js +142 -0
- package/src/standalone/index.js +3 -2
- package/src/client/lib/footerLink.js +0 -30
- package/src/client/network/WebSocketTransport.js +0 -78
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import {
|
|
2
|
+
VOICE_DENY,
|
|
3
|
+
VOICE_END,
|
|
4
|
+
VOICE_REQUEST_MIN_INTERVAL_MS,
|
|
5
|
+
VOICE_REQUEST_TIMEOUT_MS,
|
|
6
|
+
} from '../../config/voiceProtocol.js';
|
|
7
|
+
|
|
8
|
+
// Разброс автозахвата по освобождению эфира: STOP приходит всем ждущим почти
|
|
9
|
+
// одновременно, и без разброса эфир всегда доставался бы игроку с наименьшим
|
|
10
|
+
// пингом до хоста. Только для повтора по STOP: повтор по кулдауну
|
|
11
|
+
// индивидуален.
|
|
12
|
+
const AUTO_RETRY_JITTER_MS = 60;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Машина клавиши PTT: удержание → запрос эфира → разговор. Чистая логика:
|
|
16
|
+
* действия и таймеры внедряются. Состояния: idle, acquiring, requesting,
|
|
17
|
+
* waiting (автозахват при занятом эфире или кулдауне), talking, latched
|
|
18
|
+
* (эфир оборван или отказан — нужно отпустить клавишу, автопродления нет).
|
|
19
|
+
*/
|
|
20
|
+
export default class PttMachine {
|
|
21
|
+
/**
|
|
22
|
+
* @param {Object} deps
|
|
23
|
+
* @param {Object} deps.config - { maxDurationMs, cooldownMs }.
|
|
24
|
+
* @param {Object} deps.effects - {
|
|
25
|
+
* sendRequest(), sendRelease(talkId), startCapture(talkId),
|
|
26
|
+
* stopCapture(), acquireMic() → Promise<boolean>, micState() → string,
|
|
27
|
+
* canCapture() → boolean, play(cue), hud(kind, data) }.
|
|
28
|
+
* @param {Object} deps.timers - { setTimeout, clearTimeout }.
|
|
29
|
+
* @param {Function} deps.now - мс.
|
|
30
|
+
* @param {Function} [deps.random] - [0, 1).
|
|
31
|
+
*/
|
|
32
|
+
constructor({ config, effects, timers, now, random = Math.random }) {
|
|
33
|
+
this._config = config;
|
|
34
|
+
this._fx = effects;
|
|
35
|
+
this._timers = timers;
|
|
36
|
+
this._now = now;
|
|
37
|
+
this._random = random;
|
|
38
|
+
|
|
39
|
+
this._state = 'idle';
|
|
40
|
+
// растёт на каждом нажатии: ответ acquireMic прошлого удержания не
|
|
41
|
+
// трогает новое
|
|
42
|
+
this._holdId = 0;
|
|
43
|
+
this._talkId = null;
|
|
44
|
+
this._cooldownUntil = 0;
|
|
45
|
+
// сигнал отказа звучит один раз за удержание
|
|
46
|
+
this._deniedThisHold = false;
|
|
47
|
+
this._waitReason = null;
|
|
48
|
+
this._requestTimer = null;
|
|
49
|
+
this._retryTimer = null;
|
|
50
|
+
this._talkTimer = null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get state() {
|
|
54
|
+
return this._state;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Клавиша вниз. */
|
|
58
|
+
press() {
|
|
59
|
+
this._holdId += 1;
|
|
60
|
+
this._deniedThisHold = false;
|
|
61
|
+
|
|
62
|
+
if (this._state !== 'idle') {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!this._fx.canCapture()) {
|
|
67
|
+
this._fail('unsupported');
|
|
68
|
+
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// без готового микрофона разрешение спрашивается на каждом нажатии, и
|
|
73
|
+
// после отказа тоже: закрытый запрос браузер задаст снова, а
|
|
74
|
+
// заблокированный сайт getUserMedia отвергнет сразу, без окна
|
|
75
|
+
if (this._fx.micState() !== 'ready') {
|
|
76
|
+
const hold = this._holdId;
|
|
77
|
+
|
|
78
|
+
this._state = 'acquiring';
|
|
79
|
+
this._fx.acquireMic().then(
|
|
80
|
+
ok => (ok ? this._micReady(hold) : this._micFailed(hold)),
|
|
81
|
+
() => this._micFailed(hold),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this._attempt();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Клавиша вверх / потеря фокуса. */
|
|
91
|
+
releaseKey() {
|
|
92
|
+
switch (this._state) {
|
|
93
|
+
case 'acquiring':
|
|
94
|
+
this._state = 'idle';
|
|
95
|
+
break;
|
|
96
|
+
|
|
97
|
+
case 'requesting':
|
|
98
|
+
this._clearRequestTimer();
|
|
99
|
+
this._fx.hud('hidden');
|
|
100
|
+
this._state = 'idle';
|
|
101
|
+
break;
|
|
102
|
+
|
|
103
|
+
case 'waiting':
|
|
104
|
+
this._clearRetryTimer();
|
|
105
|
+
this._waitReason = null;
|
|
106
|
+
this._showCooldown();
|
|
107
|
+
this._state = 'idle';
|
|
108
|
+
break;
|
|
109
|
+
|
|
110
|
+
case 'talking':
|
|
111
|
+
this._finishOwn();
|
|
112
|
+
this._state = 'idle';
|
|
113
|
+
break;
|
|
114
|
+
|
|
115
|
+
case 'latched':
|
|
116
|
+
this._showCooldown();
|
|
117
|
+
this._state = 'idle';
|
|
118
|
+
break;
|
|
119
|
+
|
|
120
|
+
default:
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
onGrant(talkId, maxMs) {
|
|
125
|
+
if (this._state === 'requesting') {
|
|
126
|
+
this._clearRequestTimer();
|
|
127
|
+
this._talkId = talkId;
|
|
128
|
+
this._state = 'talking';
|
|
129
|
+
this._fx.startCapture(talkId);
|
|
130
|
+
this._fx.play('talkStart');
|
|
131
|
+
this._fx.hud('talking', {
|
|
132
|
+
endsAt: this._now() + maxMs,
|
|
133
|
+
totalMs: maxMs,
|
|
134
|
+
});
|
|
135
|
+
this._talkTimer = this._timers.setTimeout(() => {
|
|
136
|
+
this._talkTimer = null;
|
|
137
|
+
this._finishOwn();
|
|
138
|
+
this._state = 'latched';
|
|
139
|
+
}, maxMs);
|
|
140
|
+
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (this._state === 'talking' && this._talkId === talkId) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// эфир, который клиент уже не держит (поздний ответ, повторный GRANT)
|
|
149
|
+
this._fx.sendRelease(talkId);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
onDeny(reason, retryAfterMs) {
|
|
153
|
+
if (this._state !== 'requesting') {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
this._clearRequestTimer();
|
|
158
|
+
|
|
159
|
+
if (reason === VOICE_DENY.BUSY) {
|
|
160
|
+
this._waitReason = 'busy';
|
|
161
|
+
this._fx.hud('busy');
|
|
162
|
+
this._denySignal('busy');
|
|
163
|
+
this._state = 'waiting';
|
|
164
|
+
} else if (reason === VOICE_DENY.COOLDOWN) {
|
|
165
|
+
// не чаще порога хоста: retryAfterMs около 0 дал бы цикл запросов
|
|
166
|
+
const wait = Math.max(retryAfterMs, VOICE_REQUEST_MIN_INTERVAL_MS);
|
|
167
|
+
|
|
168
|
+
this._waitReason = 'cooldown';
|
|
169
|
+
this._fx.hud('cooldown', { endsAt: this._now() + retryAfterMs });
|
|
170
|
+
this._denySignal('deny');
|
|
171
|
+
this._armRetry(wait);
|
|
172
|
+
this._state = 'waiting';
|
|
173
|
+
} else {
|
|
174
|
+
this._fail('unavailable');
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
onEnd(talkId, reason) {
|
|
179
|
+
if (this._state !== 'talking' || talkId !== this._talkId) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
this._clearTalkTimer();
|
|
184
|
+
this._fx.stopCapture();
|
|
185
|
+
this._fx.play('talkEnd');
|
|
186
|
+
this._cooldownUntil = this._now() + this._config.cooldownMs;
|
|
187
|
+
this._talkId = null;
|
|
188
|
+
|
|
189
|
+
if (reason === VOICE_END.REVOKED) {
|
|
190
|
+
this._fx.hud('error', { textKey: 'unavailable' });
|
|
191
|
+
} else {
|
|
192
|
+
this._showCooldown();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
this._state = 'latched';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** STOP слышимого эфира или страховка, погасившая эфир без STOP. */
|
|
199
|
+
onChannelFree() {
|
|
200
|
+
if (
|
|
201
|
+
this._state === 'waiting' &&
|
|
202
|
+
this._waitReason === 'busy' &&
|
|
203
|
+
this._retryTimer === null
|
|
204
|
+
) {
|
|
205
|
+
this._armRetry(Math.floor(this._random() * AUTO_RETRY_JITTER_MS));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Чужой START: канал занял другой. */
|
|
210
|
+
onChannelBusy() {
|
|
211
|
+
if (
|
|
212
|
+
this._state === 'waiting' &&
|
|
213
|
+
this._waitReason === 'busy' &&
|
|
214
|
+
this._retryTimer !== null
|
|
215
|
+
) {
|
|
216
|
+
// запрос был бы заведомо проигрышным: ждать следующего STOP
|
|
217
|
+
this._clearRetryTimer();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Свой захват умер: энкодер упал, граф снят. */
|
|
222
|
+
onLocalFailure() {
|
|
223
|
+
switch (this._state) {
|
|
224
|
+
case 'requesting':
|
|
225
|
+
// пришедший позже GRANT отпустит onGrant (состояние уже не requesting)
|
|
226
|
+
this._clearRequestTimer();
|
|
227
|
+
this._fail('unavailable');
|
|
228
|
+
break;
|
|
229
|
+
|
|
230
|
+
case 'waiting':
|
|
231
|
+
this._clearRetryTimer();
|
|
232
|
+
this._waitReason = null;
|
|
233
|
+
this._fail('unavailable');
|
|
234
|
+
break;
|
|
235
|
+
|
|
236
|
+
case 'talking':
|
|
237
|
+
this._finishOwn();
|
|
238
|
+
this._fx.hud('error', { textKey: 'unavailable' });
|
|
239
|
+
this._state = 'latched';
|
|
240
|
+
break;
|
|
241
|
+
|
|
242
|
+
default:
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Пауза сессии / закрытие: тихо в idle. RELEASE не шлётся: эфир при
|
|
248
|
+
* паузе снимает хост (заморозка, отсоединение, D15).
|
|
249
|
+
*/
|
|
250
|
+
reset() {
|
|
251
|
+
this._clearRequestTimer();
|
|
252
|
+
this._clearRetryTimer();
|
|
253
|
+
this._clearTalkTimer();
|
|
254
|
+
|
|
255
|
+
if (this._state === 'talking') {
|
|
256
|
+
this._fx.stopCapture();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
this._talkId = null;
|
|
260
|
+
this._waitReason = null;
|
|
261
|
+
this._state = 'idle';
|
|
262
|
+
this._fx.hud('hidden');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
_micReady(hold) {
|
|
266
|
+
if (this._state === 'acquiring' && hold === this._holdId) {
|
|
267
|
+
this._attempt();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// micBlocked — только отказ в разрешении; нет устройства, занято,
|
|
272
|
+
// контекст сменился — unavailable
|
|
273
|
+
_micFailed(hold) {
|
|
274
|
+
if (this._state === 'acquiring' && hold === this._holdId) {
|
|
275
|
+
this._fail(
|
|
276
|
+
this._fx.micState() === 'denied' ? 'micBlocked' : 'unavailable',
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ошибка с вспышкой в HUD: до отпускания клавиши ничего не повторяется
|
|
282
|
+
_fail(textKey) {
|
|
283
|
+
this._fx.hud('error', { textKey });
|
|
284
|
+
this._fx.play('deny');
|
|
285
|
+
this._state = 'latched';
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
_attempt() {
|
|
289
|
+
const left = this._cooldownUntil - this._now();
|
|
290
|
+
|
|
291
|
+
if (left > 0) {
|
|
292
|
+
this._waitReason = 'cooldown';
|
|
293
|
+
this._showCooldown();
|
|
294
|
+
this._denySignal('deny');
|
|
295
|
+
this._armRetry(left);
|
|
296
|
+
this._state = 'waiting';
|
|
297
|
+
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
this._fx.sendRequest();
|
|
302
|
+
this._requestTimer = this._timers.setTimeout(() => {
|
|
303
|
+
// поздний GRANT отпустит onGrant (состояние уже не requesting)
|
|
304
|
+
this._requestTimer = null;
|
|
305
|
+
this._fail('unavailable');
|
|
306
|
+
}, VOICE_REQUEST_TIMEOUT_MS);
|
|
307
|
+
this._state = 'requesting';
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// конец своего эфира: захват, RELEASE, сигнал, кулдаун
|
|
311
|
+
_finishOwn() {
|
|
312
|
+
const talkId = this._talkId;
|
|
313
|
+
|
|
314
|
+
this._clearTalkTimer();
|
|
315
|
+
this._fx.stopCapture();
|
|
316
|
+
this._fx.sendRelease(talkId);
|
|
317
|
+
this._fx.play('talkEnd');
|
|
318
|
+
this._cooldownUntil = this._now() + this._config.cooldownMs;
|
|
319
|
+
this._showCooldown();
|
|
320
|
+
this._talkId = null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
_denySignal(cue) {
|
|
324
|
+
if (!this._deniedThisHold) {
|
|
325
|
+
this._fx.play(cue);
|
|
326
|
+
this._deniedThisHold = true;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
_showCooldown() {
|
|
331
|
+
if (this._now() < this._cooldownUntil) {
|
|
332
|
+
this._fx.hud('cooldown', { endsAt: this._cooldownUntil });
|
|
333
|
+
} else {
|
|
334
|
+
this._fx.hud('hidden');
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
_armRetry(ms) {
|
|
339
|
+
this._clearRetryTimer();
|
|
340
|
+
this._retryTimer = this._timers.setTimeout(() => {
|
|
341
|
+
this._retryTimer = null;
|
|
342
|
+
|
|
343
|
+
if (this._state === 'waiting') {
|
|
344
|
+
this._attempt();
|
|
345
|
+
}
|
|
346
|
+
}, ms);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
_clearRequestTimer() {
|
|
350
|
+
if (this._requestTimer !== null) {
|
|
351
|
+
this._timers.clearTimeout(this._requestTimer);
|
|
352
|
+
this._requestTimer = null;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
_clearRetryTimer() {
|
|
357
|
+
if (this._retryTimer !== null) {
|
|
358
|
+
this._timers.clearTimeout(this._retryTimer);
|
|
359
|
+
this._retryTimer = null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
_clearTalkTimer() {
|
|
364
|
+
if (this._talkTimer !== null) {
|
|
365
|
+
this._timers.clearTimeout(this._talkTimer);
|
|
366
|
+
this._talkTimer = null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { VOICE_LISTENER_SAFETY_MS } from '../../config/voiceProtocol.js';
|
|
2
|
+
import {
|
|
3
|
+
floorReleaseMessage,
|
|
4
|
+
floorRequestMessage,
|
|
5
|
+
} from '../../lib/voiceMessages.js';
|
|
6
|
+
import { toBytes } from '../../lib/voicePacket.js';
|
|
7
|
+
import MicCapture from './MicCapture.js';
|
|
8
|
+
import PttMachine from './PttMachine.js';
|
|
9
|
+
import VoiceCues from './VoiceCues.js';
|
|
10
|
+
import VoiceEncoder from './VoiceEncoder.js';
|
|
11
|
+
import VoicePlayback from './VoicePlayback.js';
|
|
12
|
+
import { detectVoiceSupport } from './voiceSupport.js';
|
|
13
|
+
|
|
14
|
+
// страховка индикаторов и автозахвата проверяется раз в секунду
|
|
15
|
+
const SAFETY_TICK_MS = 1000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Оркестратор голосовой связи клиента: клавиша PTT → машина → запросы эфира
|
|
19
|
+
* и захват → пакеты; VOICE_DATA → HUD, сигналы, воспроизведение.
|
|
20
|
+
*/
|
|
21
|
+
export default class VoiceClient {
|
|
22
|
+
/**
|
|
23
|
+
* @param {Object} deps
|
|
24
|
+
* @param {Object} deps.config - CONFIG_DATA.voice (нормализованный).
|
|
25
|
+
* @param {Function} deps.sendFloor - payload => void (порт VOICE_FLOOR).
|
|
26
|
+
* @param {Function} deps.sendAudio - Uint8Array => void.
|
|
27
|
+
* @param {Object} deps.soundManager - getAudioOutput().
|
|
28
|
+
* @param {Object} deps.localPlayer - { id, is(id) }.
|
|
29
|
+
* @param {Object} deps.hud - контроллер HUD: showOwn, setIncoming.
|
|
30
|
+
* @param {Object} deps.voiceStore - хранилище lib/voiceState.js.
|
|
31
|
+
* @param {Function} [deps.voiceSounds] - ClientPlugin.hooks.voiceSounds.
|
|
32
|
+
* @param {Function} [deps.voiceFilter] - ClientPlugin.hooks.voiceFilter.
|
|
33
|
+
* @param {Object} [deps.diagnostics] - { warn, capture }.
|
|
34
|
+
* @param {Object} [deps.platform] - globals, timers, now, random,
|
|
35
|
+
* mediaDevices, createWorkletNode, createEncoder, createDecoder,
|
|
36
|
+
* createAudioData, createChunk; тесты подставляют фейки. Своего
|
|
37
|
+
* AudioContext нет: захват и вывод — на контексте движка.
|
|
38
|
+
*/
|
|
39
|
+
constructor({
|
|
40
|
+
config,
|
|
41
|
+
sendFloor,
|
|
42
|
+
sendAudio,
|
|
43
|
+
soundManager,
|
|
44
|
+
localPlayer,
|
|
45
|
+
hud,
|
|
46
|
+
voiceStore,
|
|
47
|
+
voiceSounds,
|
|
48
|
+
voiceFilter,
|
|
49
|
+
diagnostics,
|
|
50
|
+
platform = {},
|
|
51
|
+
}) {
|
|
52
|
+
const globals = platform.globals ?? globalThis;
|
|
53
|
+
|
|
54
|
+
this._config = config;
|
|
55
|
+
this._sendFloor = sendFloor;
|
|
56
|
+
this._sendAudio = sendAudio;
|
|
57
|
+
this._soundManager = soundManager;
|
|
58
|
+
this._localPlayer = localPlayer;
|
|
59
|
+
this._hud = hud;
|
|
60
|
+
this._store = voiceStore;
|
|
61
|
+
this._voiceSounds = voiceSounds;
|
|
62
|
+
this._voiceFilter = voiceFilter;
|
|
63
|
+
this._diagnostics = diagnostics;
|
|
64
|
+
this._globals = globals;
|
|
65
|
+
this._timers = platform.timers ?? {
|
|
66
|
+
setTimeout: (...args) => globals.setTimeout(...args),
|
|
67
|
+
clearTimeout: id => globals.clearTimeout(id),
|
|
68
|
+
setInterval: (...args) => globals.setInterval(...args),
|
|
69
|
+
clearInterval: id => globals.clearInterval(id),
|
|
70
|
+
};
|
|
71
|
+
this._now = platform.now ?? (() => globals.performance.now());
|
|
72
|
+
this._random = platform.random;
|
|
73
|
+
this._mediaDevices =
|
|
74
|
+
platform.mediaDevices ?? globals.navigator?.mediaDevices;
|
|
75
|
+
this._createWorkletNode = platform.createWorkletNode;
|
|
76
|
+
this._createEncoder =
|
|
77
|
+
platform.createEncoder ?? (init => new globals.AudioEncoder(init));
|
|
78
|
+
this._createDecoder =
|
|
79
|
+
platform.createDecoder ?? (init => new globals.AudioDecoder(init));
|
|
80
|
+
this._createAudioData =
|
|
81
|
+
platform.createAudioData ?? (init => new globals.AudioData(init));
|
|
82
|
+
this._createChunk =
|
|
83
|
+
platform.createChunk ?? (init => new globals.EncodedAudioChunk(init));
|
|
84
|
+
|
|
85
|
+
this._machine = null;
|
|
86
|
+
this._mic = null;
|
|
87
|
+
this._encoder = null;
|
|
88
|
+
this._playback = null;
|
|
89
|
+
this._interval = null;
|
|
90
|
+
// чужой эфир, показанный в HUD: { speakerId, talkId, at }
|
|
91
|
+
this._incoming = null;
|
|
92
|
+
this._destroyed = false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async init() {
|
|
96
|
+
const getOutput = () => this._soundManager.getAudioOutput();
|
|
97
|
+
const support = await detectVoiceSupport({
|
|
98
|
+
globals: this._globals,
|
|
99
|
+
bitrate: this._config.bitrate,
|
|
100
|
+
getOutput,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (this._destroyed) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const cues = new VoiceCues({
|
|
108
|
+
voiceSounds: this._voiceSounds,
|
|
109
|
+
diagnostics: this._diagnostics,
|
|
110
|
+
});
|
|
111
|
+
const output = getOutput();
|
|
112
|
+
|
|
113
|
+
if (output) {
|
|
114
|
+
// хук игры зовётся здесь; сбои уходят в предупреждения внутри VoiceCues
|
|
115
|
+
Promise.resolve(cues.prepare(output.ctx)).catch(() => {});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (support.capture) {
|
|
119
|
+
this._encoder = new VoiceEncoder({
|
|
120
|
+
config: support.encoderConfig,
|
|
121
|
+
createEncoder: this._createEncoder,
|
|
122
|
+
createAudioData: this._createAudioData,
|
|
123
|
+
onPacket: bytes => this._sendAudio(bytes),
|
|
124
|
+
onError: error => {
|
|
125
|
+
// после destroy() энкодер разбирается вместе с матчем —
|
|
126
|
+
// предупреждение было бы шумом
|
|
127
|
+
if (this._destroyed) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
this._diagnostics?.warn('voice.encode', { message: error?.message });
|
|
132
|
+
this._failLater();
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
this._mic = new MicCapture({
|
|
136
|
+
mediaDevices: this._mediaDevices,
|
|
137
|
+
getContext: () => getOutput()?.ctx ?? null,
|
|
138
|
+
createWorkletNode: this._createWorkletNode,
|
|
139
|
+
timers: this._timers,
|
|
140
|
+
onFrame: (samples, timestampUs) =>
|
|
141
|
+
this._encoder.encode(samples, timestampUs),
|
|
142
|
+
// контекст закрыт или микрофон выдернут посреди эфира: эфир
|
|
143
|
+
// отпускается сразу, а не «говорит» тишиной до таймаута
|
|
144
|
+
onLost: () => this._machine.onLocalFailure(),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (output) {
|
|
149
|
+
this._playback = new VoicePlayback({
|
|
150
|
+
getOutput,
|
|
151
|
+
cues,
|
|
152
|
+
voiceFilter: this._voiceFilter,
|
|
153
|
+
createDecoder: support.playback ? this._createDecoder : null,
|
|
154
|
+
createChunk: this._createChunk,
|
|
155
|
+
timers: this._timers,
|
|
156
|
+
now: this._now,
|
|
157
|
+
diagnostics: this._diagnostics,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
this._machine = new PttMachine({
|
|
162
|
+
config: this._config,
|
|
163
|
+
timers: this._timers,
|
|
164
|
+
now: this._now,
|
|
165
|
+
random: this._random,
|
|
166
|
+
effects: {
|
|
167
|
+
sendRequest: () => this._sendFloor(floorRequestMessage()),
|
|
168
|
+
sendRelease: talkId => this._sendFloor(floorReleaseMessage(talkId)),
|
|
169
|
+
startCapture: talkId => {
|
|
170
|
+
this._encoder.begin(talkId);
|
|
171
|
+
|
|
172
|
+
if (!this._mic.start()) {
|
|
173
|
+
// граф умер между нажатием и выдачей
|
|
174
|
+
this._failLater();
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
stopCapture: () => {
|
|
178
|
+
this._mic?.stop();
|
|
179
|
+
this._encoder?.end();
|
|
180
|
+
},
|
|
181
|
+
acquireMic: () => this._mic.acquire(),
|
|
182
|
+
micState: () => this._mic?.state ?? 'idle',
|
|
183
|
+
canCapture: () => support.capture,
|
|
184
|
+
play: cue => this._playback?.playCue(cue, this._localPlayer.id ?? 0),
|
|
185
|
+
hud: (kind, data) => this._hud.showOwn(kind, data),
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
this._interval = this._timers.setInterval(
|
|
190
|
+
() => this._expire(),
|
|
191
|
+
SAFETY_TICK_MS,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Клавиша PTT (от Controls). */
|
|
196
|
+
setPttHeld(held) {
|
|
197
|
+
if (held) {
|
|
198
|
+
this._machine?.press();
|
|
199
|
+
} else {
|
|
200
|
+
this._machine?.releaseKey();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Разобранное parseVoiceMessage. */
|
|
205
|
+
handleFloorMessage(message) {
|
|
206
|
+
const machine = this._machine;
|
|
207
|
+
|
|
208
|
+
if (!machine) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
switch (message.type) {
|
|
213
|
+
case 'grant':
|
|
214
|
+
machine.onGrant(message.talkId, message.maxDurationMs);
|
|
215
|
+
break;
|
|
216
|
+
|
|
217
|
+
case 'deny':
|
|
218
|
+
machine.onDeny(message.reason, message.retryAfterMs);
|
|
219
|
+
break;
|
|
220
|
+
|
|
221
|
+
case 'end':
|
|
222
|
+
machine.onEnd(message.talkId, message.reason);
|
|
223
|
+
break;
|
|
224
|
+
|
|
225
|
+
case 'start':
|
|
226
|
+
// свой эфир ведёт хранилище индикаторов, не воспроизведение
|
|
227
|
+
if (!this._localPlayer.is(message.speakerId)) {
|
|
228
|
+
this._playback?.beginTalk({
|
|
229
|
+
speakerId: message.speakerId,
|
|
230
|
+
talkId: message.talkId,
|
|
231
|
+
});
|
|
232
|
+
this._hud.setIncoming({
|
|
233
|
+
name: message.name,
|
|
234
|
+
teamId: message.teamId,
|
|
235
|
+
color: message.color,
|
|
236
|
+
});
|
|
237
|
+
this._incoming = {
|
|
238
|
+
speakerId: message.speakerId,
|
|
239
|
+
talkId: message.talkId,
|
|
240
|
+
at: this._now(),
|
|
241
|
+
};
|
|
242
|
+
machine.onChannelBusy();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
break;
|
|
246
|
+
|
|
247
|
+
case 'stop':
|
|
248
|
+
if (!this._localPlayer.is(message.speakerId)) {
|
|
249
|
+
this._playback?.endTalk({
|
|
250
|
+
speakerId: message.speakerId,
|
|
251
|
+
talkId: message.talkId,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
if (
|
|
255
|
+
this._incoming?.speakerId === message.speakerId &&
|
|
256
|
+
this._incoming.talkId === message.talkId
|
|
257
|
+
) {
|
|
258
|
+
this._hud.setIncoming(null);
|
|
259
|
+
this._incoming = null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
machine.onChannelFree();
|
|
264
|
+
break;
|
|
265
|
+
|
|
266
|
+
default:
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Голосовой пакет от транспорта. */
|
|
271
|
+
handleAudio(data) {
|
|
272
|
+
const bytes = toBytes(data);
|
|
273
|
+
|
|
274
|
+
if (bytes) {
|
|
275
|
+
this._playback?.push(bytes);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Пауза сессии: тишина, HUD скрыт. */
|
|
280
|
+
suspend() {
|
|
281
|
+
this._machine?.reset();
|
|
282
|
+
this._playback?.stopAll();
|
|
283
|
+
this._hud.showOwn('hidden');
|
|
284
|
+
this._hud.setIncoming(null);
|
|
285
|
+
this._incoming = null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Закрытие матча. */
|
|
289
|
+
destroy() {
|
|
290
|
+
this._destroyed = true;
|
|
291
|
+
this.suspend();
|
|
292
|
+
this._mic?.destroy();
|
|
293
|
+
this._encoder?.destroy();
|
|
294
|
+
this._playback?.destroy();
|
|
295
|
+
|
|
296
|
+
if (this._interval !== null) {
|
|
297
|
+
this._timers.clearInterval(this._interval);
|
|
298
|
+
this._interval = null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Сбой эффекта машины (энкодер, граф в startCapture): эффект бросает
|
|
303
|
+
// посреди перехода, и onLocalFailure вошёл бы в него повторно (двойной
|
|
304
|
+
// RELEASE и talkEnd) — машина узнаёт о сбое после перехода
|
|
305
|
+
_failLater() {
|
|
306
|
+
queueMicrotask(() => {
|
|
307
|
+
if (!this._destroyed) {
|
|
308
|
+
this._machine?.onLocalFailure();
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Страховка без STOP (вкладка хоста пропала, слушатель переподключался):
|
|
314
|
+
// индикаторы, звук и автозахват не залипают
|
|
315
|
+
_expire() {
|
|
316
|
+
const limit = this._config.maxDurationMs + VOICE_LISTENER_SAFETY_MS;
|
|
317
|
+
const removed = this._store.expire(limit);
|
|
318
|
+
const expired = this._playback?.expire(limit) ?? [];
|
|
319
|
+
let freed = removed > 0 || expired.length > 0;
|
|
320
|
+
const incoming = this._incoming;
|
|
321
|
+
|
|
322
|
+
if (
|
|
323
|
+
incoming &&
|
|
324
|
+
(expired.includes(incoming.speakerId) ||
|
|
325
|
+
this._now() - incoming.at > limit)
|
|
326
|
+
) {
|
|
327
|
+
this._hud.setIncoming(null);
|
|
328
|
+
this._incoming = null;
|
|
329
|
+
freed = true;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (freed) {
|
|
333
|
+
this._machine?.onChannelFree();
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|