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,295 @@
1
+ import { VOICE_CUES, synthesizeTone } from './voiceTones.js';
2
+
3
+ // с большим запасом: два входящих сигнала на каждого из 64 участников плюс
4
+ // свои сигналы и bed — в полной комнате записи не вытесняют друг друга.
5
+ // Потолка байт нет: вытесненный сигнал синтезировался бы заново в момент
6
+ // звука; память держат короткие сигналы игры (plugin-api.md)
7
+ const GAME_CUE_CACHE_MAX = 256;
8
+ // потолок звука игры, секунд: bed — петля, остальные — короткие сигналы
9
+ const MAX_BED_S = 10;
10
+ const MAX_CUE_S = 2;
11
+ const BED_FADE_IN_S = 0.02;
12
+ const BED_FADE_OUT_S = 0.05;
13
+
14
+ const isPlainObject = value => {
15
+ if (value === null || typeof value !== 'object') {
16
+ return false;
17
+ }
18
+
19
+ const proto = Object.getPrototypeOf(value);
20
+
21
+ return proto === Object.prototype || proto === null;
22
+ };
23
+
24
+ /**
25
+ * Сигналы связи: звук игры из хука `voiceSounds` или нейтральный тон
26
+ * движка, кэш буферов, петля `bed`.
27
+ */
28
+ export default class VoiceCues {
29
+ /**
30
+ * @param {Object} [deps]
31
+ * @param {Function} [deps.voiceSounds] - ClientPlugin.hooks.voiceSounds.
32
+ * @param {Object} [deps.diagnostics] - { warn(code, details) }.
33
+ */
34
+ constructor({ voiceSounds, diagnostics } = {}) {
35
+ this._hook = voiceSounds;
36
+ this._diagnostics = diagnostics;
37
+ this._rate = null;
38
+ this._promise = null;
39
+ // Map<сигнал, null | функция> — проверенный ответ хука
40
+ this._set = null;
41
+ this._warned = new Set();
42
+ this._cacheCtx = null;
43
+ this._toneCache = new Map();
44
+ this._gameCache = new Map();
45
+ this._bed = null;
46
+ }
47
+
48
+ /**
49
+ * Вызывает хук игры для частоты контекста (один раз на частоту) и ждёт
50
+ * набор; без хука — сразу. Пока промис не разрешён, звучат тоны движка.
51
+ * @param {BaseAudioContext} ctx
52
+ * @returns {Promise<void>}
53
+ */
54
+ prepare(ctx) {
55
+ const rate = ctx.sampleRate;
56
+
57
+ if (this._promise && this._rate === rate) {
58
+ return this._promise;
59
+ }
60
+
61
+ this._rate = rate;
62
+ this._set = null;
63
+
64
+ if (typeof this._hook !== 'function') {
65
+ this._promise = Promise.resolve();
66
+
67
+ return this._promise;
68
+ }
69
+
70
+ const promise = Promise.resolve()
71
+ .then(() => this._hook({ sampleRate: rate }))
72
+ .then(answer => {
73
+ if (this._promise === promise) {
74
+ this._set = this._validateSet(answer);
75
+ }
76
+ })
77
+ .catch(error => {
78
+ if (this._promise === promise) {
79
+ this._warn('hook', { message: error?.message });
80
+ }
81
+ });
82
+
83
+ this._promise = promise;
84
+
85
+ return promise;
86
+ }
87
+
88
+ /**
89
+ * Ставит сигнал на destination в момент when.
90
+ * @param {string} cue - из VOICE_CUES.
91
+ * @param {Object} target
92
+ * @param {BaseAudioContext} target.ctx
93
+ * @param {AudioNode} target.destination
94
+ * @param {number} target.when - секунды контекста.
95
+ * @param {number|null} [target.speakerId] - свой id у своих сигналов,
96
+ * id говорящего у входящих.
97
+ * @returns {number} длительность в секундах; 0 — сигнал молчит.
98
+ */
99
+ play(cue, { ctx, destination, when, speakerId = null }) {
100
+ const buffer = this._resolve(cue, ctx, speakerId);
101
+
102
+ if (!buffer) {
103
+ return 0;
104
+ }
105
+
106
+ const source = ctx.createBufferSource();
107
+
108
+ source.buffer = buffer;
109
+ source.connect(destination);
110
+ source.start(when);
111
+
112
+ return buffer.duration;
113
+ }
114
+
115
+ /** @returns {boolean} игра дала петлю bed. */
116
+ hasBed() {
117
+ return typeof this._set?.get('bed') === 'function';
118
+ }
119
+
120
+ /** Плавно запускает петлю bed в момент when (без bed — ничего). */
121
+ startBed({ ctx, destination, when }) {
122
+ if (this._bed || !this.hasBed()) {
123
+ return;
124
+ }
125
+
126
+ const buffer = this._resolve('bed', ctx, null);
127
+
128
+ if (!buffer) {
129
+ return;
130
+ }
131
+
132
+ const source = ctx.createBufferSource();
133
+ const gain = ctx.createGain();
134
+
135
+ source.buffer = buffer;
136
+ source.loop = true;
137
+ gain.gain.setValueAtTime(0, when);
138
+ gain.gain.linearRampToValueAtTime(1, when + BED_FADE_IN_S);
139
+ source.connect(gain);
140
+ gain.connect(destination);
141
+ source.start(when);
142
+ this._bed = { source, gain, ctx };
143
+ }
144
+
145
+ /** Плавно гасит петлю bed в момент when. */
146
+ stopBed(when) {
147
+ const bed = this._bed;
148
+
149
+ if (!bed) {
150
+ return;
151
+ }
152
+
153
+ this._bed = null;
154
+ bed.gain.gain.setValueAtTime(1, when);
155
+ bed.gain.gain.linearRampToValueAtTime(0, when + BED_FADE_OUT_S);
156
+ bed.source.stop(when + BED_FADE_OUT_S + 0.01);
157
+ }
158
+
159
+ /** Контекст пересоздан: буферы старого — в мусор. */
160
+ forgetContext() {
161
+ this._cacheCtx = null;
162
+ this._toneCache.clear();
163
+ this._gameCache.clear();
164
+ this._bed = null;
165
+ }
166
+
167
+ _validateSet(answer) {
168
+ if (!isPlainObject(answer)) {
169
+ this._warn('set', {
170
+ message: 'voiceSounds must resolve to a plain object',
171
+ });
172
+
173
+ return null;
174
+ }
175
+
176
+ const set = new Map();
177
+
178
+ for (const [key, value] of Object.entries(answer)) {
179
+ if (!VOICE_CUES.includes(key)) {
180
+ this._warn(`key:${key}`, { key, message: 'unknown voice cue' });
181
+ } else if (value !== null && typeof value !== 'function') {
182
+ this._warn(`value:${key}`, {
183
+ cue: key,
184
+ message: 'voice cue must be null or a function',
185
+ });
186
+ } else {
187
+ set.set(key, value);
188
+ }
189
+ }
190
+
191
+ return set;
192
+ }
193
+
194
+ // одно предупреждение на ключ
195
+ _warn(key, details) {
196
+ if (this._warned.has(key)) {
197
+ return;
198
+ }
199
+
200
+ this._warned.add(key);
201
+ this._diagnostics?.warn('voice.sound', details);
202
+ }
203
+
204
+ _resolve(cue, ctx, speakerId) {
205
+ if (this._cacheCtx !== ctx) {
206
+ this._toneCache.clear();
207
+ this._gameCache.clear();
208
+ this._bed = null;
209
+ this._cacheCtx = ctx;
210
+ }
211
+
212
+ if (!this._set?.has(cue)) {
213
+ return this._tone(cue, ctx);
214
+ }
215
+
216
+ const fn = this._set.get(cue);
217
+
218
+ if (fn === null) {
219
+ return null;
220
+ }
221
+
222
+ const key = `${cue}:${speakerId ?? '-'}`;
223
+
224
+ if (this._gameCache.has(key)) {
225
+ const cached = this._gameCache.get(key);
226
+
227
+ // LRU: свежая запись уходит в конец
228
+ this._gameCache.delete(key);
229
+ this._gameCache.set(key, cached);
230
+
231
+ return cached;
232
+ }
233
+
234
+ let samples;
235
+
236
+ try {
237
+ samples = fn(speakerId);
238
+ } catch (error) {
239
+ this._warn(`cue:${cue}`, { cue, message: error?.message });
240
+
241
+ return this._tone(cue, ctx);
242
+ }
243
+
244
+ let buffer = null;
245
+
246
+ if (samples !== null) {
247
+ const max = ctx.sampleRate * (cue === 'bed' ? MAX_BED_S : MAX_CUE_S);
248
+
249
+ if (
250
+ !(samples instanceof Float32Array) ||
251
+ samples.length < 1 ||
252
+ samples.length > max ||
253
+ !samples.every(Number.isFinite)
254
+ ) {
255
+ this._warn(`cue:${cue}`, {
256
+ cue,
257
+ message: 'voice cue must return a finite mono Float32Array or null',
258
+ });
259
+
260
+ return this._tone(cue, ctx);
261
+ }
262
+
263
+ buffer = this._makeBuffer(ctx, samples);
264
+ }
265
+
266
+ this._gameCache.set(key, buffer);
267
+
268
+ if (this._gameCache.size > GAME_CUE_CACHE_MAX) {
269
+ this._gameCache.delete(this._gameCache.keys().next().value);
270
+ }
271
+
272
+ return buffer;
273
+ }
274
+
275
+ _tone(cue, ctx) {
276
+ if (this._toneCache.has(cue)) {
277
+ return this._toneCache.get(cue);
278
+ }
279
+
280
+ const samples = synthesizeTone(cue, ctx.sampleRate);
281
+ const buffer = samples ? this._makeBuffer(ctx, samples) : null;
282
+
283
+ this._toneCache.set(cue, buffer);
284
+
285
+ return buffer;
286
+ }
287
+
288
+ _makeBuffer(ctx, samples) {
289
+ const buffer = ctx.createBuffer(1, samples.length, ctx.sampleRate);
290
+
291
+ buffer.copyToChannel(samples, 0);
292
+
293
+ return buffer;
294
+ }
295
+ }
@@ -0,0 +1,174 @@
1
+ import wsports from '../../config/wsports.js';
2
+ import {
3
+ VOICE_MAX_PACKET_BYTES,
4
+ VOICE_SAMPLE_RATE,
5
+ } from '../../config/voiceProtocol.js';
6
+ import { encodeVoicePacket } from '../../lib/voicePacket.js';
7
+
8
+ // глубже очередь энкодера — CPU не успевает: живость важнее полноты
9
+ const MAX_QUEUE = 4;
10
+
11
+ /**
12
+ * Кадры микрофона → голосовые пакеты (Opus через WebCodecs).
13
+ */
14
+ export default class VoiceEncoder {
15
+ /**
16
+ * @param {Object} deps
17
+ * @param {Object} deps.config - конфиг энкодера из detectVoiceSupport.
18
+ * @param {Function} deps.createEncoder - init => AudioEncoder.
19
+ * @param {Function} deps.createAudioData - init => AudioData.
20
+ * @param {Function} deps.onPacket - (Uint8Array) => void.
21
+ * @param {Function} [deps.onError] - (Error) => void.
22
+ */
23
+ constructor({ config, createEncoder, createAudioData, onPacket, onError }) {
24
+ this._config = config;
25
+ this._createEncoder = createEncoder;
26
+ this._createAudioData = createAudioData;
27
+ this._onPacket = onPacket;
28
+ this._onError = onError;
29
+ this._encoder = null;
30
+ this._configured = false;
31
+ this._talkId = null;
32
+ this._seq = 0;
33
+ }
34
+
35
+ /**
36
+ * Начинает эфир: seq с нуля, энкодер сконфигурирован.
37
+ * @param {number} talkId
38
+ */
39
+ begin(talkId) {
40
+ if (!this._encoder) {
41
+ const encoder = this._createEncoder({
42
+ // чанки выброшенного энкодера не попадают в поток нового эфира
43
+ output: chunk => {
44
+ if (encoder === this._encoder) {
45
+ this._onChunk(chunk);
46
+ }
47
+ },
48
+ // колбэк уже выброшенного энкодера не трогает новый
49
+ error: error => this._fail(error, encoder),
50
+ });
51
+
52
+ this._encoder = encoder;
53
+ this._configured = false;
54
+ }
55
+
56
+ if (!this._configured) {
57
+ this._encoder.configure(this._config);
58
+ this._configured = true;
59
+ }
60
+
61
+ this._talkId = talkId;
62
+ this._seq = 0;
63
+ }
64
+
65
+ /**
66
+ * @param {Float32Array} samples - кадр 20 мс, 48 кГц, моно.
67
+ * @param {number} timestampUs
68
+ */
69
+ encode(samples, timestampUs) {
70
+ if (this._talkId === null || !this._encoder) {
71
+ return;
72
+ }
73
+
74
+ if (this._encoder.encodeQueueSize > MAX_QUEUE) {
75
+ return;
76
+ }
77
+
78
+ const audioData = this._createAudioData({
79
+ format: 'f32-planar',
80
+ sampleRate: VOICE_SAMPLE_RATE,
81
+ numberOfFrames: samples.length,
82
+ numberOfChannels: 1,
83
+ timestamp: timestampUs,
84
+ data: samples,
85
+ });
86
+
87
+ try {
88
+ this._encoder.encode(audioData);
89
+ } catch (error) {
90
+ // закрытый энкодер бросает синхронно: тот же сбой, что колбэк error
91
+ this._fail(error, this._encoder);
92
+ } finally {
93
+ audioData.close();
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Заканчивает эфир. reset(), а не flush(): после RELEASE хост снимает
99
+ * маршрут, хвостовые пакеты ретранслятор всё равно выбросил бы. Бросок
100
+ * reset() — сбой энкодера, как в encode().
101
+ */
102
+ end() {
103
+ this._talkId = null;
104
+
105
+ if (!this._encoder || !this._configured) {
106
+ return;
107
+ }
108
+
109
+ const encoder = this._encoder;
110
+
111
+ try {
112
+ encoder.reset();
113
+ this._configured = false;
114
+ } catch (error) {
115
+ // закрытый энкодер бросает синхронно, как в encode()
116
+ this._fail(error, encoder);
117
+ }
118
+ }
119
+
120
+ destroy() {
121
+ this._talkId = null;
122
+
123
+ try {
124
+ this._encoder?.close();
125
+ } catch {
126
+ // уже закрыт
127
+ }
128
+
129
+ this._encoder = null;
130
+ this._configured = false;
131
+ }
132
+
133
+ _onChunk(chunk) {
134
+ if (this._talkId === null) {
135
+ return;
136
+ }
137
+
138
+ const payload = new Uint8Array(chunk.byteLength);
139
+
140
+ chunk.copyTo(payload);
141
+
142
+ const bytes = encodeVoicePacket({
143
+ port: wsports.client.VOICE_AUDIO,
144
+ talkId: this._talkId,
145
+ seq: this._seq,
146
+ payload,
147
+ });
148
+
149
+ this._seq = (this._seq + 1) & 0xffff;
150
+
151
+ if (bytes.length <= VOICE_MAX_PACKET_BYTES) {
152
+ this._onPacket(bytes);
153
+ }
154
+ }
155
+
156
+ // сломанный энкодер выбрасывается один раз: следующий begin создаст новый
157
+ _fail(error, encoder) {
158
+ if (encoder !== this._encoder) {
159
+ return;
160
+ }
161
+
162
+ this._talkId = null;
163
+ this._encoder = null;
164
+ this._configured = false;
165
+
166
+ try {
167
+ encoder.close();
168
+ } catch {
169
+ // уже закрыт
170
+ }
171
+
172
+ this._onError?.(error);
173
+ }
174
+ }