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,558 @@
|
|
|
1
|
+
import wsports from '../../config/wsports.js';
|
|
2
|
+
import { readVoiceHeader, voicePayload } from '../../lib/voicePacket.js';
|
|
3
|
+
import JitterBuffer from './JitterBuffer.js';
|
|
4
|
+
import { audioDataToFloat32 } from './audioData.js';
|
|
5
|
+
import { decoderConfig } from './opusConfig.js';
|
|
6
|
+
import { createVoiceOutput } from './voiceOutput.js';
|
|
7
|
+
|
|
8
|
+
const PUMP_INTERVAL_MS = 10;
|
|
9
|
+
// минимальный запас на джиттер перед первым кадром
|
|
10
|
+
const START_LEAD_S = 0.06;
|
|
11
|
+
// пауза между сигналом и речью
|
|
12
|
+
const CUE_GAP_S = 0.015;
|
|
13
|
+
const MIN_LEAD_S = 0.005;
|
|
14
|
+
// накопленная задержка речи выше этого — кадр выбрасывается
|
|
15
|
+
const MAX_LATENCY_S = 0.35;
|
|
16
|
+
const MAX_SILENT_GAP_FRAMES = 5;
|
|
17
|
+
const FRAME_S = 0.02;
|
|
18
|
+
const FRAME_US = 20000;
|
|
19
|
+
// поток эфира встал: говорящий без DTX шлёт пакет каждые 20 мс
|
|
20
|
+
const SILENCE_TIMEOUT_MS = 1000;
|
|
21
|
+
// первый пакет: START приходит раньше, чем говорящий получит GRANT и
|
|
22
|
+
// запустит захват
|
|
23
|
+
const FIRST_PACKET_GRACE_MS = 2000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Приём эфиров: декодер, джиттер-буфер, планирование речи, сигналы,
|
|
27
|
+
* тишина потока. Индикаторы ведёт не он — только звук.
|
|
28
|
+
*/
|
|
29
|
+
export default class VoicePlayback {
|
|
30
|
+
/**
|
|
31
|
+
* @param {Object} deps
|
|
32
|
+
* @param {Function} deps.getOutput - () => { ctx, destination } | null.
|
|
33
|
+
* @param {import('./VoiceCues.js').default} deps.cues
|
|
34
|
+
* @param {Function} [deps.voiceFilter] - ClientPlugin.hooks.voiceFilter.
|
|
35
|
+
* @param {Function|null} deps.createDecoder - init => AudioDecoder; null —
|
|
36
|
+
* у браузера нет декодера: приём эфиров выключен, свои сигналы звучат.
|
|
37
|
+
* @param {Function} deps.createChunk - init => EncodedAudioChunk.
|
|
38
|
+
* @param {Object} deps.timers - { setInterval, clearInterval }.
|
|
39
|
+
* @param {Function} deps.now - мс (performance.now).
|
|
40
|
+
* @param {Object} [deps.diagnostics] - { warn(code, details) }.
|
|
41
|
+
*/
|
|
42
|
+
constructor({
|
|
43
|
+
getOutput,
|
|
44
|
+
cues,
|
|
45
|
+
voiceFilter,
|
|
46
|
+
createDecoder,
|
|
47
|
+
createChunk,
|
|
48
|
+
timers,
|
|
49
|
+
now,
|
|
50
|
+
diagnostics,
|
|
51
|
+
}) {
|
|
52
|
+
this._getOutput = getOutput;
|
|
53
|
+
this._cues = cues;
|
|
54
|
+
this._voiceFilter = voiceFilter;
|
|
55
|
+
this._createDecoder = createDecoder;
|
|
56
|
+
this._createChunk = createChunk;
|
|
57
|
+
this._timers = timers;
|
|
58
|
+
this._now = now;
|
|
59
|
+
this._diagnostics = diagnostics;
|
|
60
|
+
this._talks = new Map();
|
|
61
|
+
// { ctx, out } — выход, построенный на контексте Howler
|
|
62
|
+
this._built = null;
|
|
63
|
+
// эфиры, под которыми звучит bed
|
|
64
|
+
this._audible = 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** START чужого эфира. */
|
|
68
|
+
beginTalk({ speakerId, talkId }) {
|
|
69
|
+
if (!this._createDecoder) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const built = this._ensureOutput();
|
|
74
|
+
|
|
75
|
+
if (!built) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const existing = this._talks.get(speakerId);
|
|
80
|
+
|
|
81
|
+
if (existing) {
|
|
82
|
+
// тот же эфир, живой или спящий, — ничего; закрывающийся (дочитывается
|
|
83
|
+
// после STOP или засыпает) — слушатель вернулся в канал: приём заново
|
|
84
|
+
if (
|
|
85
|
+
existing.talkId === talkId &&
|
|
86
|
+
!existing.ending &&
|
|
87
|
+
!existing.finishing
|
|
88
|
+
) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// устаревший эфир (talkId после смены хоста начинается заново) или
|
|
93
|
+
// закрывающийся — снимается без сигналов
|
|
94
|
+
this._drop(existing);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this._resume(built.ctx);
|
|
98
|
+
|
|
99
|
+
const talk = {
|
|
100
|
+
speakerId,
|
|
101
|
+
talkId,
|
|
102
|
+
startedAt: this._now(),
|
|
103
|
+
jb: new JitterBuffer(),
|
|
104
|
+
decoder: null,
|
|
105
|
+
playhead: null,
|
|
106
|
+
notBefore: 0,
|
|
107
|
+
missingRun: 0,
|
|
108
|
+
ending: false,
|
|
109
|
+
finishing: false,
|
|
110
|
+
dormant: false,
|
|
111
|
+
lastPacketAt: this._now(),
|
|
112
|
+
gotPacket: false,
|
|
113
|
+
warned: false,
|
|
114
|
+
bed: false,
|
|
115
|
+
sources: new Set(),
|
|
116
|
+
pump: null,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
this._talks.set(speakerId, talk);
|
|
120
|
+
this._safeOpen(talk, built);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Голосовой пакет (host → client). */
|
|
124
|
+
push(bytes) {
|
|
125
|
+
if (!this._createDecoder) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const header = readVoiceHeader(bytes);
|
|
130
|
+
|
|
131
|
+
if (!header || header.port !== wsports.server.VOICE_AUDIO) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const talk = this._talks.get(header.speakerId);
|
|
136
|
+
|
|
137
|
+
if (!talk || talk.talkId !== header.talkId || talk.finishing) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (talk.dormant && !this._wake(talk)) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
talk.lastPacketAt = this._now();
|
|
146
|
+
talk.gotPacket = true;
|
|
147
|
+
// копия: буфер может принадлежать ретранслятору
|
|
148
|
+
talk.jb.push(header.seq, voicePayload(bytes).slice(), this._now());
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** STOP: эфир дочитывается из очереди и закрывается сигналом конца. */
|
|
152
|
+
endTalk({ speakerId, talkId }) {
|
|
153
|
+
const talk = this._talks.get(speakerId);
|
|
154
|
+
|
|
155
|
+
if (!talk || talk.talkId !== talkId) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (talk.dormant) {
|
|
160
|
+
// сигнал конца уже звучал
|
|
161
|
+
this._talks.delete(speakerId);
|
|
162
|
+
} else {
|
|
163
|
+
talk.ending = true;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Свой сигнал: talkStart | talkEnd | busy | deny.
|
|
169
|
+
* @returns {number} длительность в секундах.
|
|
170
|
+
*/
|
|
171
|
+
playCue(cue, speakerId) {
|
|
172
|
+
const built = this._ensureOutput();
|
|
173
|
+
|
|
174
|
+
if (!built) {
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
this._resume(built.ctx);
|
|
179
|
+
|
|
180
|
+
return this._cues.play(cue, {
|
|
181
|
+
ctx: built.ctx,
|
|
182
|
+
destination: built.out.cueInput,
|
|
183
|
+
when: built.ctx.currentTime,
|
|
184
|
+
speakerId,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Страховка без STOP.
|
|
190
|
+
* @param {number} maxAgeMs
|
|
191
|
+
* @returns {Array<number>} speakerId эфиров старше maxAgeMs.
|
|
192
|
+
*/
|
|
193
|
+
expire(maxAgeMs) {
|
|
194
|
+
if (!this._createDecoder) {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const expired = [];
|
|
199
|
+
|
|
200
|
+
for (const talk of [...this._talks.values()]) {
|
|
201
|
+
if (talk.ending) {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (this._now() - talk.startedAt > maxAgeMs) {
|
|
206
|
+
expired.push(talk.speakerId);
|
|
207
|
+
|
|
208
|
+
if (talk.dormant) {
|
|
209
|
+
this._talks.delete(talk.speakerId);
|
|
210
|
+
} else {
|
|
211
|
+
talk.ending = true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return expired;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Немедленная тишина. */
|
|
220
|
+
stopAll() {
|
|
221
|
+
for (const talk of [...this._talks.values()]) {
|
|
222
|
+
this._drop(talk);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
this._audible = 0;
|
|
226
|
+
this._cues.stopBed(this._built?.ctx.currentTime ?? 0);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
destroy() {
|
|
230
|
+
this.stopAll();
|
|
231
|
+
this._built?.out.dispose();
|
|
232
|
+
this._cues.forgetContext();
|
|
233
|
+
this._built = null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// выход строится лениво на текущем контексте Howler; контекст сменился —
|
|
237
|
+
// всё старое разобрано
|
|
238
|
+
_ensureOutput() {
|
|
239
|
+
const output = this._getOutput();
|
|
240
|
+
|
|
241
|
+
if (!output) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (this._built && this._built.ctx !== output.ctx) {
|
|
246
|
+
this.stopAll();
|
|
247
|
+
this._built.out.dispose();
|
|
248
|
+
this._cues.forgetContext();
|
|
249
|
+
this._built = null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!this._built) {
|
|
253
|
+
this._built = {
|
|
254
|
+
ctx: output.ctx,
|
|
255
|
+
out: createVoiceOutput(output.ctx, output.destination, {
|
|
256
|
+
voiceFilter: this._voiceFilter,
|
|
257
|
+
diagnostics: this._diagnostics,
|
|
258
|
+
}),
|
|
259
|
+
};
|
|
260
|
+
Promise.resolve(this._cues.prepare(output.ctx)).catch(() => {});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return this._built;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// без жеста браузер resume отклонит — Howler разблокирует контекст сам
|
|
267
|
+
_resume(ctx) {
|
|
268
|
+
if (ctx.state === 'suspended') {
|
|
269
|
+
try {
|
|
270
|
+
ctx.resume()?.catch?.(() => {});
|
|
271
|
+
} catch {
|
|
272
|
+
// игнор
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// сбой открытия (закрывающийся контекст) снимает эфир, а не оставляет
|
|
278
|
+
// запись без декодера и насоса
|
|
279
|
+
_safeOpen(talk, built) {
|
|
280
|
+
try {
|
|
281
|
+
this._open(talk, built);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
this._onDecoderError(talk, error);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// общий шаг beginTalk и wake: сигнал, bed, декодер, насос
|
|
288
|
+
_open(talk, built) {
|
|
289
|
+
const { ctx, out } = built;
|
|
290
|
+
const t0 = ctx.currentTime;
|
|
291
|
+
const cueSec = this._cues.play('incomingStart', {
|
|
292
|
+
ctx,
|
|
293
|
+
destination: out.cueInput,
|
|
294
|
+
when: t0,
|
|
295
|
+
speakerId: talk.speakerId,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
talk.notBefore =
|
|
299
|
+
t0 + Math.max(START_LEAD_S, cueSec > 0 ? cueSec + CUE_GAP_S : 0);
|
|
300
|
+
|
|
301
|
+
if (this._cues.hasBed()) {
|
|
302
|
+
talk.bed = true;
|
|
303
|
+
this._audible += 1;
|
|
304
|
+
|
|
305
|
+
if (this._audible === 1) {
|
|
306
|
+
this._cues.startBed({
|
|
307
|
+
ctx,
|
|
308
|
+
destination: out.cueInput,
|
|
309
|
+
when: talk.notBefore,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
talk.decoder = this._createDecoder({
|
|
316
|
+
output: audioData => this._onAudio(talk, audioData),
|
|
317
|
+
error: error => this._onDecoderError(talk, error),
|
|
318
|
+
});
|
|
319
|
+
talk.decoder.configure(decoderConfig());
|
|
320
|
+
} catch (error) {
|
|
321
|
+
this._onDecoderError(talk, error);
|
|
322
|
+
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
talk.pump = this._timers.setInterval(
|
|
327
|
+
() => this._pump(talk),
|
|
328
|
+
PUMP_INTERVAL_MS,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// новый экземпляр JitterBuffer: очередь и expected прошлого куска не
|
|
333
|
+
// переживают пробуждение, а опоздавшие пакеты получают 'late'
|
|
334
|
+
_wake(talk) {
|
|
335
|
+
const built = this._ensureOutput();
|
|
336
|
+
|
|
337
|
+
if (!built) {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
talk.jb = new JitterBuffer({ after: talk.jb.highestSeq });
|
|
342
|
+
talk.playhead = null;
|
|
343
|
+
talk.missingRun = 0;
|
|
344
|
+
talk.dormant = false;
|
|
345
|
+
this._resume(built.ctx);
|
|
346
|
+
this._safeOpen(talk, built);
|
|
347
|
+
|
|
348
|
+
return this._talks.get(talk.speakerId) === talk;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
_pump(talk) {
|
|
352
|
+
if (talk.finishing) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const nowMs = this._now();
|
|
357
|
+
const silence = talk.gotPacket ? SILENCE_TIMEOUT_MS : FIRST_PACKET_GRACE_MS;
|
|
358
|
+
|
|
359
|
+
if (
|
|
360
|
+
!talk.ending &&
|
|
361
|
+
talk.jb.size === 0 &&
|
|
362
|
+
nowMs - talk.lastPacketAt > silence
|
|
363
|
+
) {
|
|
364
|
+
this._finish(talk, true);
|
|
365
|
+
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
for (const item of talk.jb.pull(nowMs, { flush: talk.ending })) {
|
|
370
|
+
if (item.missing) {
|
|
371
|
+
// короткий провал не рвёт ритм речи
|
|
372
|
+
if (talk.playhead !== null && talk.missingRun < MAX_SILENT_GAP_FRAMES) {
|
|
373
|
+
talk.playhead += FRAME_S;
|
|
374
|
+
talk.missingRun += 1;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
talk.missingRun = 0;
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
talk.decoder.decode(
|
|
384
|
+
this._createChunk({
|
|
385
|
+
type: 'key',
|
|
386
|
+
timestamp: item.seq * FRAME_US,
|
|
387
|
+
data: item.payload,
|
|
388
|
+
}),
|
|
389
|
+
);
|
|
390
|
+
} catch (error) {
|
|
391
|
+
this._onDecoderError(talk, error);
|
|
392
|
+
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (talk.ending && talk.jb.size === 0) {
|
|
398
|
+
this._finish(talk, false);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
_onAudio(talk, audioData) {
|
|
403
|
+
const built = this._built;
|
|
404
|
+
|
|
405
|
+
if (!built || this._talks.get(talk.speakerId) !== talk) {
|
|
406
|
+
audioData.close();
|
|
407
|
+
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
let samples;
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
samples = audioDataToFloat32(audioData);
|
|
415
|
+
} catch (error) {
|
|
416
|
+
audioData.close();
|
|
417
|
+
this._onDecoderError(talk, error);
|
|
418
|
+
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const rate = audioData.sampleRate;
|
|
423
|
+
|
|
424
|
+
audioData.close();
|
|
425
|
+
|
|
426
|
+
const { ctx, out } = built;
|
|
427
|
+
const now = ctx.currentTime;
|
|
428
|
+
let start = talk.playhead ?? Math.max(talk.notBefore, now + MIN_LEAD_S);
|
|
429
|
+
|
|
430
|
+
// опустошение: догнали «сейчас»
|
|
431
|
+
if (start < now + MIN_LEAD_S) {
|
|
432
|
+
start = now + MIN_LEAD_S;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// задержка не копится; отсчёт от notBefore: длинный сигнал игры сдвигает
|
|
436
|
+
// начало речи и не должен резать её кадры
|
|
437
|
+
if (start - Math.max(now, talk.notBefore) > MAX_LATENCY_S) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const buffer = ctx.createBuffer(1, samples.length, rate);
|
|
442
|
+
|
|
443
|
+
buffer.copyToChannel(samples, 0);
|
|
444
|
+
|
|
445
|
+
const source = ctx.createBufferSource();
|
|
446
|
+
|
|
447
|
+
source.buffer = buffer;
|
|
448
|
+
source.connect(out.speechInput);
|
|
449
|
+
source.start(start);
|
|
450
|
+
talk.playhead = start + samples.length / rate;
|
|
451
|
+
talk.sources.add(source);
|
|
452
|
+
source.onended = () => talk.sources.delete(source);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
_onDecoderError(talk, error) {
|
|
456
|
+
if (!talk.warned) {
|
|
457
|
+
talk.warned = true;
|
|
458
|
+
this._diagnostics?.warn('voice.decode', { message: error?.message });
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
this._drop(talk);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Закрывает передачу сигналом конца. sleep — поток встал без STOP: запись
|
|
465
|
+
// остаётся «спящей», индикатор ведёт хост, а сеть могла лишь «икнуть».
|
|
466
|
+
async _finish(talk, sleep) {
|
|
467
|
+
talk.finishing = true;
|
|
468
|
+
this._timers.clearInterval(talk.pump);
|
|
469
|
+
talk.pump = null;
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
await talk.decoder.flush();
|
|
473
|
+
} catch {
|
|
474
|
+
// ошибка досыла не мешает закрытию
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// эфир убрали stopAll/drop/новый beginTalk — звуков уже нет
|
|
478
|
+
if (this._talks.get(talk.speakerId) !== talk || !this._built) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
try {
|
|
483
|
+
const { ctx, out } = this._built;
|
|
484
|
+
const tailAt = Math.max(talk.playhead ?? 0, ctx.currentTime);
|
|
485
|
+
const cueSec = this._cues.play('incomingEnd', {
|
|
486
|
+
ctx,
|
|
487
|
+
destination: out.cueInput,
|
|
488
|
+
when: tailAt,
|
|
489
|
+
speakerId: talk.speakerId,
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
this._releaseBed(talk, tailAt + cueSec);
|
|
493
|
+
} catch (error) {
|
|
494
|
+
this._onDecoderError(talk, error);
|
|
495
|
+
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
try {
|
|
500
|
+
talk.decoder.close();
|
|
501
|
+
} catch {
|
|
502
|
+
// уже закрыт
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
talk.decoder = null;
|
|
506
|
+
|
|
507
|
+
// STOP пришёл, пока эфир засыпал: спать нечему
|
|
508
|
+
if (sleep && !talk.ending) {
|
|
509
|
+
talk.dormant = true;
|
|
510
|
+
talk.finishing = false;
|
|
511
|
+
} else {
|
|
512
|
+
this._talks.delete(talk.speakerId);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// без сигналов
|
|
517
|
+
_drop(talk) {
|
|
518
|
+
this._timers.clearInterval(talk.pump);
|
|
519
|
+
talk.pump = null;
|
|
520
|
+
|
|
521
|
+
for (const source of talk.sources) {
|
|
522
|
+
try {
|
|
523
|
+
source.stop();
|
|
524
|
+
} catch {
|
|
525
|
+
// уже остановлен
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
talk.sources.clear();
|
|
530
|
+
|
|
531
|
+
try {
|
|
532
|
+
talk.decoder?.close();
|
|
533
|
+
} catch {
|
|
534
|
+
// уже закрыт
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
talk.decoder = null;
|
|
538
|
+
this._releaseBed(talk, this._built?.ctx.currentTime ?? 0);
|
|
539
|
+
|
|
540
|
+
if (this._talks.get(talk.speakerId) === talk) {
|
|
541
|
+
this._talks.delete(talk.speakerId);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
_releaseBed(talk, when) {
|
|
546
|
+
if (!talk.bed) {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
talk.bed = false;
|
|
551
|
+
this._audible -= 1;
|
|
552
|
+
|
|
553
|
+
if (this._audible <= 0) {
|
|
554
|
+
this._audible = 0;
|
|
555
|
+
this._cues.stopBed(when);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AudioData → Float32Array (моно, первый канал). Форматы 'f32', 'f32-planar',
|
|
3
|
+
* 's16', 's16-planar'; интерливные многоканальные читаются через шаг в
|
|
4
|
+
* numberOfChannels сэмплов.
|
|
5
|
+
* @param {AudioData} audioData
|
|
6
|
+
* @returns {Float32Array}
|
|
7
|
+
* @throws {RangeError} другой формат сэмплов.
|
|
8
|
+
*/
|
|
9
|
+
export function audioDataToFloat32(audioData) {
|
|
10
|
+
const { format, numberOfFrames: frames } = audioData;
|
|
11
|
+
const channels = Math.max(1, audioData.numberOfChannels || 1);
|
|
12
|
+
const planar = typeof format === 'string' && format.endsWith('-planar');
|
|
13
|
+
const base = typeof format === 'string' ? format.replace('-planar', '') : '';
|
|
14
|
+
|
|
15
|
+
if (base !== 'f32' && base !== 's16') {
|
|
16
|
+
throw new RangeError(`voice: unsupported audio format ${format}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const Typed = base === 'f32' ? Float32Array : Int16Array;
|
|
20
|
+
const raw = new Typed(
|
|
21
|
+
audioData.allocationSize({ planeIndex: 0 }) / Typed.BYTES_PER_ELEMENT,
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
audioData.copyTo(raw, { planeIndex: 0 });
|
|
25
|
+
|
|
26
|
+
const stride = planar ? 1 : channels;
|
|
27
|
+
const scale = base === 's16' ? 1 / 32768 : 1;
|
|
28
|
+
const out = new Float32Array(frames);
|
|
29
|
+
|
|
30
|
+
for (let i = 0; i < frames; i++) {
|
|
31
|
+
out[i] = raw[i * stride] * scale;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return out;
|
|
35
|
+
}
|