vimp-engine 0.38.2 → 0.39.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.
Files changed (99) hide show
  1. package/core/Cargo.toml +1 -1
  2. package/package.json +3 -1
  3. package/src/client/InputListener.js +3 -0
  4. package/src/client/SoundManager.js +13 -0
  5. package/src/client/boot.js +30 -7
  6. package/src/client/components/controller/ClientReports.js +1 -1
  7. package/src/client/components/controller/Controls.js +10 -0
  8. package/src/client/components/controller/Games.js +1 -1
  9. package/src/client/components/controller/Voice.js +31 -0
  10. package/src/client/components/model/ClientReports.js +1 -1
  11. package/src/client/components/model/Controls.js +64 -0
  12. package/src/client/components/model/Games.js +24 -0
  13. package/src/client/components/model/Voice.js +96 -0
  14. package/src/client/components/model/Vote.js +2 -1
  15. package/src/client/components/view/ClientReports.js +1 -1
  16. package/src/client/components/view/Games.js +19 -1
  17. package/src/client/components/view/Lobby.js +1 -7
  18. package/src/client/components/view/Voice.js +172 -0
  19. package/src/client/debug.js +1 -1
  20. package/src/client/lib/authApi.js +1 -1
  21. package/src/client/lib/diagnostics.js +19 -4
  22. package/src/client/lib/engineVersion.js +0 -6
  23. package/src/client/lib/footerLink.js +15 -14
  24. package/src/client/lib/retryOnNetworkError.js +60 -0
  25. package/src/client/lib/voiceState.js +78 -0
  26. package/src/client/main.js +152 -18
  27. package/src/client/network/HiddenHostHealthLog.js +40 -3
  28. package/src/client/network/HostConnectionManager.js +46 -5
  29. package/src/client/network/HostController.js +47 -4
  30. package/src/client/network/HostHealthPolicy.js +41 -0
  31. package/src/client/network/InlineHostBridge.js +8 -1
  32. package/src/client/network/LoopbackTransport.js +14 -0
  33. package/src/client/network/SessionSupervisor.js +24 -0
  34. package/src/client/network/WebRtcManager.js +45 -3
  35. package/src/client/session/HandoffFlow.js +5 -0
  36. package/src/client/session/HostRole.js +5 -2
  37. package/src/client/style.css +151 -9
  38. package/src/client/views/gameShell.js +3 -3
  39. package/src/client/views/includes/auth.pug +4 -4
  40. package/src/client/views/includes/lobby.pug +4 -4
  41. package/src/client/views/includes/reports.pug +1 -1
  42. package/src/client/voice/JitterBuffer.js +137 -0
  43. package/src/client/voice/MicCapture.js +307 -0
  44. package/src/client/voice/PttMachine.js +369 -0
  45. package/src/client/voice/VoiceClient.js +336 -0
  46. package/src/client/voice/VoiceCues.js +295 -0
  47. package/src/client/voice/VoiceEncoder.js +174 -0
  48. package/src/client/voice/VoicePlayback.js +558 -0
  49. package/src/client/voice/audioData.js +35 -0
  50. package/src/client/voice/captureProcessor.worklet.js +136 -0
  51. package/src/client/voice/opusConfig.js +49 -0
  52. package/src/client/voice/voiceOutput.js +72 -0
  53. package/src/client/voice/voiceSupport.js +71 -0
  54. package/src/client/voice/voiceTones.js +90 -0
  55. package/src/config/authClient.js +3 -3
  56. package/src/config/clientServices.js +2 -0
  57. package/src/config/lobby.js +4 -5
  58. package/src/config/opcodes.js +2 -1
  59. package/src/config/voiceDefaults.js +44 -0
  60. package/src/config/voiceProtocol.js +80 -0
  61. package/src/config/voteKeys.js +4 -0
  62. package/src/config/wsports.js +12 -0
  63. package/src/devtools/contract/loadContext.js +2 -2
  64. package/src/devtools/contract/rules/a7-package-repository.js +7 -8
  65. package/src/devtools/contract/rules/b11-voice-config.js +34 -0
  66. package/src/devtools/contract/rules/c12-voice-hotkey.js +70 -0
  67. package/src/devtools/contract/rules/index.js +5 -1
  68. package/src/devtools/invariants.js +1 -1
  69. package/src/host/DebugRecorder.js +1 -1
  70. package/src/host/HostGame.js +93 -15
  71. package/src/host/PortMachine.js +19 -3
  72. package/src/host/commandFailure.js +140 -0
  73. package/src/host/host.worker.js +15 -2
  74. package/src/host/meta/SocketManager.js +8 -2
  75. package/src/host/meta/core/CommandProcessor.js +37 -7
  76. package/src/host/meta/modules/voice/Voice.js +430 -0
  77. package/src/host/meta/modules/voice/channels.js +58 -0
  78. package/src/host/meta/modules/voice/index.js +1 -0
  79. package/src/lib/VoiceRelay.js +200 -0
  80. package/src/lib/applyRoomOverrides.js +1 -1
  81. package/src/lib/buildClientConfig.js +7 -0
  82. package/src/lib/capabilities.js +5 -1
  83. package/src/lib/checkpointCodec.js +1 -2
  84. package/src/lib/coreConfig.js +1 -1
  85. package/src/lib/createHostRuntime.js +9 -1
  86. package/src/lib/gameConfigView.js +11 -1
  87. package/src/lib/gameId.js +1 -1
  88. package/src/lib/gamePlugin.js +6 -2
  89. package/src/lib/jwt.js +1 -1
  90. package/src/lib/packageLink.js +18 -61
  91. package/src/lib/validators.js +3 -3
  92. package/src/lib/voiceConfig.js +136 -0
  93. package/src/lib/voiceMessages.js +172 -0
  94. package/src/lib/voicePacket.js +142 -0
  95. package/src/standalone/index.js +1 -1
  96. package/tests/fixtures/generations/gen-api3/README.md +2 -2
  97. package/tests/fixtures/generations/gen-api4/README.md +2 -2
  98. package/src/devtools/surface/abiParse.js +0 -175
  99. package/src/devtools/surface/collect.js +0 -413
@@ -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
+ }