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,137 @@
1
+ import { seqDiff } from '../../lib/voicePacket.js';
2
+
3
+ /**
4
+ * Упорядочивает пакеты голоса: перестановки, дубли, потери. Чистая логика
5
+ * без таймеров — время приходит аргументом. Новый поток — новый экземпляр.
6
+ */
7
+ export default class JitterBuffer {
8
+ /**
9
+ * @param {Object} [opts]
10
+ * @param {number} [opts.maxWaitMs] - сколько ждать пропавший пакет.
11
+ * @param {number} [opts.maxQueue] - потолок очереди (1 с голоса).
12
+ * @param {number|null} [opts.after] - номер, после которого идёт поток:
13
+ * пакеты не новее него — 'late' (пробуждение эфира).
14
+ */
15
+ constructor({ maxWaitMs = 60, maxQueue = 50, after = null } = {}) {
16
+ this._maxWaitMs = maxWaitMs;
17
+ this._maxQueue = maxQueue;
18
+ this._after = after;
19
+ this._highest = after;
20
+ this._expected = null;
21
+ this._queue = new Map();
22
+ }
23
+
24
+ get size() {
25
+ return this._queue.size;
26
+ }
27
+
28
+ /** Наибольший принятый seq или after, пока ничего не принято. */
29
+ get highestSeq() {
30
+ return this._highest;
31
+ }
32
+
33
+ /**
34
+ * @param {number} seq - u16.
35
+ * @param {Uint8Array} payload
36
+ * @param {number} nowMs
37
+ * @returns {'accepted'|'late'|'duplicate'|'overflow'}
38
+ */
39
+ push(seq, payload, nowMs) {
40
+ if (this._after !== null && seqDiff(seq, this._after) <= 0) {
41
+ return 'late';
42
+ }
43
+
44
+ if (this._expected !== null && seqDiff(seq, this._expected) < 0) {
45
+ return 'late';
46
+ }
47
+
48
+ if (this._queue.has(seq)) {
49
+ return 'duplicate';
50
+ }
51
+
52
+ if (this._queue.size >= this._maxQueue) {
53
+ return 'overflow';
54
+ }
55
+
56
+ this._queue.set(seq, { payload, at: nowMs });
57
+
58
+ if (this._highest === null || seqDiff(seq, this._highest) > 0) {
59
+ this._highest = seq;
60
+ }
61
+
62
+ return 'accepted';
63
+ }
64
+
65
+ /**
66
+ * Кадры, готовые к декодированию, по порядку.
67
+ * @param {number} nowMs
68
+ * @param {Object} [opts]
69
+ * @param {boolean} [opts.flush] - выдать всё, не ожидая пропавших.
70
+ * @returns {Array<{ seq: number, payload: Uint8Array }|
71
+ * { seq: number, missing: true }>}
72
+ */
73
+ pull(nowMs, { flush = false } = {}) {
74
+ const out = [];
75
+
76
+ if (this._queue.size === 0) {
77
+ return out;
78
+ }
79
+
80
+ const waited = () => nowMs - this._oldestAt() >= this._maxWaitMs;
81
+
82
+ if (this._expected === null) {
83
+ if (!(flush || this._queue.size >= 3 || waited())) {
84
+ return out;
85
+ }
86
+
87
+ this._expected = this._lowestSeq();
88
+ }
89
+
90
+ const lowest = this._lowestSeq();
91
+
92
+ // скачок seq дальше очереди: пакеты позади него не придут, зазор не
93
+ // перебираем «потерями» — ждать нечего
94
+ if (seqDiff(lowest, this._expected) > this._maxQueue) {
95
+ this._expected = lowest;
96
+ }
97
+
98
+ while (this._queue.size > 0) {
99
+ const entry = this._queue.get(this._expected);
100
+
101
+ if (entry) {
102
+ this._queue.delete(this._expected);
103
+ out.push({ seq: this._expected, payload: entry.payload });
104
+ } else if (flush || waited()) {
105
+ out.push({ seq: this._expected, missing: true });
106
+ } else {
107
+ break;
108
+ }
109
+
110
+ this._expected = (this._expected + 1) & 0xffff;
111
+ }
112
+
113
+ return out;
114
+ }
115
+
116
+ _oldestAt() {
117
+ let oldest = Infinity;
118
+
119
+ for (const { at } of this._queue.values()) {
120
+ oldest = Math.min(oldest, at);
121
+ }
122
+
123
+ return oldest;
124
+ }
125
+
126
+ _lowestSeq() {
127
+ let lowest = null;
128
+
129
+ for (const seq of this._queue.keys()) {
130
+ if (lowest === null || seqDiff(seq, lowest) < 0) {
131
+ lowest = seq;
132
+ }
133
+ }
134
+
135
+ return lowest;
136
+ }
137
+ }
@@ -0,0 +1,307 @@
1
+ import {
2
+ VOICE_FRAME_MS,
3
+ VOICE_MIC_IDLE_RELEASE_MS,
4
+ VOICE_SAMPLE_RATE,
5
+ } from '../../config/voiceProtocol.js';
6
+
7
+ const PROCESSOR_NAME = 'vimp-voice-capture';
8
+
9
+ /**
10
+ * Микрофон: разрешение, граф захвата, «тёплое» удержание. Своего
11
+ * AudioContext у захвата нет — он сидит на контексте движка (Howler): свой
12
+ * контекст на 48 кГц ломается в Firefox при другой частоте микрофона и
13
+ * ненадёжен в Safari/iOS. Частоту выравнивает worklet. Howler контекст
14
+ * пересоздаёт, поэтому он берётся лениво и сверяется по идентичности.
15
+ */
16
+ export default class MicCapture {
17
+ /**
18
+ * @param {Object} deps
19
+ * @param {MediaDevices} deps.mediaDevices
20
+ * @param {Function} deps.getContext - () => AudioContext | null.
21
+ * @param {Function} [deps.createWorkletNode] - (ctx, name, opts) => node.
22
+ * @param {URL|string} [deps.workletUrl]
23
+ * @param {Object} [deps.timers] - { setTimeout, clearTimeout }.
24
+ * @param {number} [deps.idleReleaseMs]
25
+ * @param {Function} deps.onFrame - (Float32Array, timestampUs) => void.
26
+ * @param {Function} [deps.onLost] - готовый граф умер (контекст закрыт,
27
+ * микрофон выдернут): эфир надо завершить.
28
+ */
29
+ constructor({
30
+ mediaDevices,
31
+ getContext,
32
+ createWorkletNode = (ctx, name, opts) =>
33
+ new globalThis.AudioWorkletNode(ctx, name, opts),
34
+ workletUrl = new URL('./captureProcessor.worklet.js', import.meta.url),
35
+ timers = {
36
+ setTimeout: (...args) => globalThis.setTimeout(...args),
37
+ clearTimeout: id => globalThis.clearTimeout(id),
38
+ },
39
+ idleReleaseMs = VOICE_MIC_IDLE_RELEASE_MS,
40
+ onFrame,
41
+ onLost,
42
+ }) {
43
+ this._mediaDevices = mediaDevices;
44
+ this._getContext = getContext;
45
+ this._createWorkletNode = createWorkletNode;
46
+ this._workletUrl = workletUrl;
47
+ this._timers = timers;
48
+ this._idleReleaseMs = idleReleaseMs;
49
+ this._onFrame = onFrame;
50
+ this._onLost = onLost;
51
+ this._state = 'idle';
52
+ this._graph = null;
53
+ this._acquiring = null;
54
+ this._timer = null;
55
+ this._running = false;
56
+ this._frameIndex = 0;
57
+ this._generation = 0;
58
+ this._destroyed = false;
59
+ // контексты, в которые модуль worklet уже загружен
60
+ this._modules = new WeakSet();
61
+ }
62
+
63
+ /** 'idle' | 'acquiring' | 'ready' | 'denied' | 'failed' */
64
+ get state() {
65
+ return this._state === 'ready' && !this._isAlive() ? 'idle' : this._state;
66
+ }
67
+
68
+ /** @returns {Promise<boolean>} граф готов. */
69
+ acquire() {
70
+ if (this._acquiring) {
71
+ return this._acquiring;
72
+ }
73
+
74
+ if (this._state === 'ready' && this._isAlive()) {
75
+ return Promise.resolve(true);
76
+ }
77
+
78
+ // граф устарел (контекст пересоздан): разобрать и построить заново
79
+ this.release();
80
+ this._state = 'acquiring';
81
+ this._acquiring = this._open().finally(() => {
82
+ this._acquiring = null;
83
+ });
84
+
85
+ return this._acquiring;
86
+ }
87
+
88
+ /** @returns {boolean} false — графа нет или он устарел. */
89
+ start() {
90
+ if (!this._graph || !this._isAlive()) {
91
+ return false;
92
+ }
93
+
94
+ this._clearTimer();
95
+
96
+ const { ctx, node } = this._graph;
97
+
98
+ if (ctx.state === 'suspended') {
99
+ // нажатие V — жест пользователя
100
+ ctx.resume()?.catch?.(() => {});
101
+ }
102
+
103
+ this._frameIndex = 0;
104
+ this._running = true;
105
+ // поколение: кадры прошлого эфира, ещё стоящие в очереди порта, не
106
+ // попадают в новый
107
+ this._generation += 1;
108
+ node.port.postMessage({ active: true, generation: this._generation });
109
+
110
+ return true;
111
+ }
112
+
113
+ stop() {
114
+ if (!this._graph) {
115
+ return;
116
+ }
117
+
118
+ this._running = false;
119
+ this._graph.node.port.postMessage({ active: false });
120
+ this._armTimer();
121
+ }
122
+
123
+ /** Останавливает треки и отключает узлы; контекст чужой, не закрывается. */
124
+ release() {
125
+ this._clearTimer();
126
+ this._running = false;
127
+
128
+ const graph = this._graph;
129
+
130
+ if (!graph) {
131
+ return;
132
+ }
133
+
134
+ this._graph = null;
135
+ this._state = 'idle';
136
+ graph.ctx.removeEventListener('statechange', graph.onStateChange);
137
+
138
+ for (const track of graph.stream.getTracks()) {
139
+ track.onended = null;
140
+ track.stop();
141
+ }
142
+
143
+ // процессор с process() → true жил бы вечно: отключённый узел не собрать
144
+ try {
145
+ graph.node.port.postMessage({ shutdown: true });
146
+ } catch {
147
+ // порт уже закрыт
148
+ }
149
+
150
+ graph.node.port.onmessage = null;
151
+
152
+ for (const node of [graph.source, graph.node, graph.silence]) {
153
+ try {
154
+ node.disconnect();
155
+ } catch {
156
+ // уже отключён
157
+ }
158
+ }
159
+ }
160
+
161
+ destroy() {
162
+ this._destroyed = true;
163
+ this.release();
164
+ }
165
+
166
+ _isAlive() {
167
+ return Boolean(this._graph && this._isCurrent(this._graph.ctx));
168
+ }
169
+
170
+ // контекст жив и всё ещё контекст движка (Howler пересоздаёт его)
171
+ _isCurrent(ctx) {
172
+ return ctx.state !== 'closed' && this._getContext() === ctx;
173
+ }
174
+
175
+ async _open() {
176
+ const ctx = this._getContext();
177
+
178
+ if (!ctx) {
179
+ this._state = 'failed';
180
+
181
+ return false;
182
+ }
183
+
184
+ let stream;
185
+
186
+ try {
187
+ stream = await this._mediaDevices.getUserMedia({
188
+ audio: {
189
+ echoCancellation: true,
190
+ noiseSuppression: true,
191
+ autoGainControl: true,
192
+ channelCount: 1,
193
+ },
194
+ });
195
+ } catch (error) {
196
+ this._state =
197
+ error?.name === 'NotAllowedError' || error?.name === 'SecurityError'
198
+ ? 'denied'
199
+ : 'failed';
200
+
201
+ return false;
202
+ }
203
+
204
+ // захват закрыт или контекст сменился, пока ждали разрешения или модуль:
205
+ // граф после destroy() держал бы микрофон открытым до таймера «тёплого»
206
+ // удержания
207
+ const usable = () => !this._destroyed && this._isCurrent(ctx);
208
+ let graph = null;
209
+
210
+ try {
211
+ if (usable() && !this._modules.has(ctx)) {
212
+ await ctx.audioWorklet.addModule(this._workletUrl);
213
+ this._modules.add(ctx);
214
+ }
215
+
216
+ if (usable()) {
217
+ graph = this._buildGraph(ctx, stream);
218
+ }
219
+ } catch {
220
+ // модуль не загрузился или граф не собрался — как устаревший контекст
221
+ }
222
+
223
+ if (!graph) {
224
+ for (const track of stream.getTracks()) {
225
+ track.stop();
226
+ }
227
+
228
+ this._state = 'failed';
229
+
230
+ return false;
231
+ }
232
+
233
+ this._graph = graph;
234
+ this._state = 'ready';
235
+ this._armTimer();
236
+
237
+ return true;
238
+ }
239
+
240
+ _buildGraph(ctx, stream) {
241
+ const source = ctx.createMediaStreamSource(stream);
242
+ const node = this._createWorkletNode(ctx, PROCESSOR_NAME, {
243
+ numberOfInputs: 1,
244
+ numberOfOutputs: 1,
245
+ processorOptions: {
246
+ outputRate: VOICE_SAMPLE_RATE,
247
+ frameSamples: (VOICE_SAMPLE_RATE * VOICE_FRAME_MS) / 1000,
248
+ },
249
+ });
250
+ // нулевой выход держит узел в обработке; ctx.destination, а не master
251
+ // gain Howler: mute движка захват не трогает
252
+ const silence = ctx.createGain();
253
+
254
+ silence.gain.value = 0;
255
+ source.connect(node);
256
+ node.connect(silence);
257
+ silence.connect(ctx.destination);
258
+
259
+ const onStateChange = () => {
260
+ if (ctx.state === 'closed' && this._graph?.ctx === ctx) {
261
+ this.release();
262
+ this._onLost?.();
263
+ }
264
+ };
265
+ const graph = { ctx, stream, source, node, silence, onStateChange };
266
+
267
+ // addEventListener, не onstatechange: свойство может занимать Howler
268
+ ctx.addEventListener('statechange', onStateChange);
269
+
270
+ node.port.onmessage = ({ data }) => {
271
+ if (
272
+ this._running &&
273
+ this._graph === graph &&
274
+ data.generation === this._generation
275
+ ) {
276
+ this._onFrame(data.samples, this._frameIndex++ * VOICE_FRAME_MS * 1000);
277
+ }
278
+ };
279
+
280
+ for (const track of stream.getAudioTracks()) {
281
+ track.onended = () => {
282
+ if (this._graph === graph) {
283
+ this.release();
284
+ this._state = 'failed';
285
+ this._onLost?.();
286
+ }
287
+ };
288
+ }
289
+
290
+ return graph;
291
+ }
292
+
293
+ _armTimer() {
294
+ this._clearTimer();
295
+ this._timer = this._timers.setTimeout(
296
+ () => this.release(),
297
+ this._idleReleaseMs,
298
+ );
299
+ }
300
+
301
+ _clearTimer() {
302
+ if (this._timer !== null) {
303
+ this._timers.clearTimeout(this._timer);
304
+ this._timer = null;
305
+ }
306
+ }
307
+ }