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
|
@@ -16,9 +16,6 @@ div#auth
|
|
|
16
16
|
|
|
17
17
|
div#auth-link
|
|
18
18
|
//- версия пакета ИГРЫ (мастер кладёт её в манифест, читая package.json
|
|
19
|
-
//- пакета)
|
|
20
|
-
//- проставляет main.js по AUTH_DATA; без данных ячейки остаются пустыми
|
|
21
|
-
p
|
|
22
|
-
a#auth-docs-link
|
|
19
|
+
//- пакета) — проставляет main.js по AUTH_DATA; без данных ячейка пустая
|
|
23
20
|
p#auth-version
|
|
24
21
|
p © #{new Date().getFullYear()} VIMP
|
|
@@ -32,8 +32,7 @@ div#lobby(style='display:none')
|
|
|
32
32
|
label.form-label(for='lobby-game')= 'Select game'
|
|
33
33
|
select#lobby-game.field-select
|
|
34
34
|
//- поля настроек комнаты генерируются по явной схеме roomForm
|
|
35
|
-
//- манифеста активной игры (docs
|
|
36
|
-
//- schema")
|
|
35
|
+
//- манифеста активной игры (VIMP docs: plugin-api.md § "Form schema")
|
|
37
36
|
div#lobby-fields
|
|
38
37
|
//- ошибки формы (formBuilder.renderFormErrors) и отказ загрузки
|
|
39
38
|
//- плагина выбранной игры — строкой в форме, а не в #tech-informer:
|
|
@@ -67,15 +66,13 @@ div#lobby(style='display:none')
|
|
|
67
66
|
span#leaderboard-total
|
|
68
67
|
ol#lobby-leaderboard-list
|
|
69
68
|
p#lobby-my-placement
|
|
70
|
-
//- футер:
|
|
71
|
-
//-
|
|
69
|
+
//- футер: Terms/Privacy, версия пакета движка (проставляет LobbyView,
|
|
70
|
+
//- engineVersion.js), копирайт
|
|
72
71
|
div#lobby-footer
|
|
73
72
|
p
|
|
74
73
|
a#lobby-terms-link(target='_blank', rel='noopener') Terms of Service
|
|
75
74
|
| ·
|
|
76
75
|
|
|
|
77
76
|
a#lobby-privacy-link(target='_blank', rel='noopener') Privacy Policy
|
|
78
|
-
p
|
|
79
|
-
a#lobby-link(href='https://docs.vimp.lgick.dev', target='_blank', rel='noopener') docs
|
|
80
77
|
p#lobby-version
|
|
81
78
|
p © #{new Date().getFullYear()} VIMP
|
|
@@ -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
|
+
}
|