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.
Files changed (88) hide show
  1. package/README.md +2 -2
  2. package/bin/vimp-sim.js +2 -2
  3. package/core/Cargo.toml +2 -2
  4. package/package.json +3 -4
  5. package/src/client/InputListener.js +3 -0
  6. package/src/client/SoundManager.js +13 -0
  7. package/src/client/boot.js +11 -87
  8. package/src/client/components/controller/Controls.js +10 -0
  9. package/src/client/components/controller/Voice.js +31 -0
  10. package/src/client/components/model/Controls.js +64 -0
  11. package/src/client/components/model/RoomMenu.js +1 -1
  12. package/src/client/components/model/Voice.js +96 -0
  13. package/src/client/components/model/Vote.js +2 -1
  14. package/src/client/components/view/Auth.js +3 -3
  15. package/src/client/components/view/Voice.js +172 -0
  16. package/src/client/lib/formBuilder.js +7 -6
  17. package/src/client/lib/hostVoteCommand.js +2 -2
  18. package/src/client/lib/pickActiveGame.js +3 -12
  19. package/src/client/lib/retryOnNetworkError.js +0 -9
  20. package/src/client/lib/voiceState.js +78 -0
  21. package/src/client/main.js +160 -90
  22. package/src/client/network/HostConnectionManager.js +46 -5
  23. package/src/client/network/HostController.js +43 -0
  24. package/src/client/network/LoopbackTransport.js +14 -0
  25. package/src/client/network/SessionSupervisor.js +38 -14
  26. package/src/client/network/WebRtcManager.js +46 -5
  27. package/src/client/network/policyClose.js +10 -77
  28. package/src/client/style.css +150 -9
  29. package/src/client/views/gameShell.js +2 -4
  30. package/src/client/views/includes/auth.pug +1 -4
  31. package/src/client/views/includes/lobby.pug +3 -6
  32. package/src/client/voice/JitterBuffer.js +137 -0
  33. package/src/client/voice/MicCapture.js +307 -0
  34. package/src/client/voice/PttMachine.js +369 -0
  35. package/src/client/voice/VoiceClient.js +336 -0
  36. package/src/client/voice/VoiceCues.js +295 -0
  37. package/src/client/voice/VoiceEncoder.js +174 -0
  38. package/src/client/voice/VoicePlayback.js +558 -0
  39. package/src/client/voice/audioData.js +35 -0
  40. package/src/client/voice/captureProcessor.worklet.js +136 -0
  41. package/src/client/voice/opusConfig.js +49 -0
  42. package/src/client/voice/voiceOutput.js +72 -0
  43. package/src/client/voice/voiceSupport.js +71 -0
  44. package/src/client/voice/voiceTones.js +90 -0
  45. package/src/config/clientServices.js +2 -0
  46. package/src/config/closeCodes.js +7 -9
  47. package/src/config/gameCodes.js +1 -1
  48. package/src/config/hostDefaults.js +2 -2
  49. package/src/config/lobby.js +3 -4
  50. package/src/config/voiceDefaults.js +44 -0
  51. package/src/config/voiceProtocol.js +80 -0
  52. package/src/config/voteKeys.js +4 -0
  53. package/src/config/wsports.js +13 -1
  54. package/src/devtools/ScenarioRunner.js +1 -1
  55. package/src/devtools/builtinScenario.js +2 -2
  56. package/src/devtools/contract/rules/a2-package-scripts.js +1 -1
  57. package/src/devtools/contract/rules/a4-vite-config.js +1 -1
  58. package/src/devtools/contract/rules/b11-voice-config.js +34 -0
  59. package/src/devtools/contract/rules/c12-voice-hotkey.js +70 -0
  60. package/src/devtools/contract/rules/c4-component-dependencies.js +1 -1
  61. package/src/devtools/contract/rules/index.js +4 -0
  62. package/src/devtools/invariants.js +1 -1
  63. package/src/devtools/pluginLoader.js +2 -2
  64. package/src/host/HostGame.js +62 -4
  65. package/src/host/PortMachine.js +23 -24
  66. package/src/host/commandFailure.js +2 -2
  67. package/src/host/host.worker.js +7 -0
  68. package/src/host/identity.js +3 -3
  69. package/src/host/meta/SocketManager.js +6 -0
  70. package/src/host/meta/core/CommandProcessor.js +2 -2
  71. package/src/host/meta/modules/Accolades.js +1 -1
  72. package/src/host/meta/modules/voice/Voice.js +430 -0
  73. package/src/host/meta/modules/voice/channels.js +58 -0
  74. package/src/host/meta/modules/voice/index.js +1 -0
  75. package/src/lib/VoiceRelay.js +200 -0
  76. package/src/lib/buildClientConfig.js +7 -0
  77. package/src/lib/capabilities.js +4 -0
  78. package/src/lib/createHostRuntime.js +8 -0
  79. package/src/lib/gameConfigView.js +11 -1
  80. package/src/lib/loadGamePackage.js +7 -9
  81. package/src/lib/offlinePlayerData.js +1 -1
  82. package/src/lib/validators.js +2 -2
  83. package/src/lib/voiceConfig.js +136 -0
  84. package/src/lib/voiceMessages.js +172 -0
  85. package/src/lib/voicePacket.js +142 -0
  86. package/src/standalone/index.js +3 -2
  87. package/src/client/lib/footerLink.js +0 -30
  88. 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
+ }