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.
- package/core/Cargo.toml +1 -1
- package/package.json +3 -1
- package/src/client/InputListener.js +3 -0
- package/src/client/SoundManager.js +13 -0
- package/src/client/boot.js +30 -7
- package/src/client/components/controller/ClientReports.js +1 -1
- package/src/client/components/controller/Controls.js +10 -0
- package/src/client/components/controller/Games.js +1 -1
- package/src/client/components/controller/Voice.js +31 -0
- package/src/client/components/model/ClientReports.js +1 -1
- package/src/client/components/model/Controls.js +64 -0
- package/src/client/components/model/Games.js +24 -0
- package/src/client/components/model/Voice.js +96 -0
- package/src/client/components/model/Vote.js +2 -1
- package/src/client/components/view/ClientReports.js +1 -1
- package/src/client/components/view/Games.js +19 -1
- package/src/client/components/view/Lobby.js +1 -7
- package/src/client/components/view/Voice.js +172 -0
- package/src/client/debug.js +1 -1
- package/src/client/lib/authApi.js +1 -1
- package/src/client/lib/diagnostics.js +19 -4
- package/src/client/lib/engineVersion.js +0 -6
- package/src/client/lib/footerLink.js +15 -14
- package/src/client/lib/retryOnNetworkError.js +60 -0
- package/src/client/lib/voiceState.js +78 -0
- package/src/client/main.js +152 -18
- package/src/client/network/HiddenHostHealthLog.js +40 -3
- package/src/client/network/HostConnectionManager.js +46 -5
- package/src/client/network/HostController.js +47 -4
- package/src/client/network/HostHealthPolicy.js +41 -0
- package/src/client/network/InlineHostBridge.js +8 -1
- package/src/client/network/LoopbackTransport.js +14 -0
- package/src/client/network/SessionSupervisor.js +24 -0
- package/src/client/network/WebRtcManager.js +45 -3
- package/src/client/session/HandoffFlow.js +5 -0
- package/src/client/session/HostRole.js +5 -2
- package/src/client/style.css +151 -9
- package/src/client/views/gameShell.js +3 -3
- package/src/client/views/includes/auth.pug +4 -4
- package/src/client/views/includes/lobby.pug +4 -4
- package/src/client/views/includes/reports.pug +1 -1
- 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/authClient.js +3 -3
- package/src/config/clientServices.js +2 -0
- package/src/config/lobby.js +4 -5
- package/src/config/opcodes.js +2 -1
- 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 +12 -0
- package/src/devtools/contract/loadContext.js +2 -2
- package/src/devtools/contract/rules/a7-package-repository.js +7 -8
- 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/index.js +5 -1
- package/src/devtools/invariants.js +1 -1
- package/src/host/DebugRecorder.js +1 -1
- package/src/host/HostGame.js +93 -15
- package/src/host/PortMachine.js +19 -3
- package/src/host/commandFailure.js +140 -0
- package/src/host/host.worker.js +15 -2
- package/src/host/meta/SocketManager.js +8 -2
- package/src/host/meta/core/CommandProcessor.js +37 -7
- 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/applyRoomOverrides.js +1 -1
- package/src/lib/buildClientConfig.js +7 -0
- package/src/lib/capabilities.js +5 -1
- package/src/lib/checkpointCodec.js +1 -2
- package/src/lib/coreConfig.js +1 -1
- package/src/lib/createHostRuntime.js +9 -1
- package/src/lib/gameConfigView.js +11 -1
- package/src/lib/gameId.js +1 -1
- package/src/lib/gamePlugin.js +6 -2
- package/src/lib/jwt.js +1 -1
- package/src/lib/packageLink.js +18 -61
- package/src/lib/validators.js +3 -3
- 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 +1 -1
- package/tests/fixtures/generations/gen-api3/README.md +2 -2
- package/tests/fixtures/generations/gen-api4/README.md +2 -2
- package/src/devtools/surface/abiParse.js +0 -175
- package/src/devtools/surface/collect.js +0 -413
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// AudioWorkletProcessor захвата микрофона. Самодостаточный файл без
|
|
2
|
+
// импортов: грузится audioWorklet.addModule отдельно от бандла (same-origin,
|
|
3
|
+
// CSP не ослабляется).
|
|
4
|
+
//
|
|
5
|
+
// Контекст движка работает на частоте устройства (44.1/48/96 кГц), кодек
|
|
6
|
+
// ждёт 48 кГц — процессор сам приводит поток к outputRate и режет его на
|
|
7
|
+
// кадры frameSamples. Ресемплинг — линейная интерполяция с дробной фазой,
|
|
8
|
+
// непрерывной между квантами и кадрами; при входе выше outputRate перед
|
|
9
|
+
// прореживанием стоит однополюсный ФНЧ против наложения спектра.
|
|
10
|
+
//
|
|
11
|
+
// `{ shutdown: true }` останавливает процессор навсегда: process() возвращает
|
|
12
|
+
// false, и отключённый узел можно собрать (true держал бы его живым вечно).
|
|
13
|
+
|
|
14
|
+
// срез ФНЧ — доля от outputRate
|
|
15
|
+
const LOWPASS_RATIO = 0.45;
|
|
16
|
+
|
|
17
|
+
class VoiceCaptureProcessor extends AudioWorkletProcessor {
|
|
18
|
+
constructor(options) {
|
|
19
|
+
super();
|
|
20
|
+
|
|
21
|
+
const { outputRate, frameSamples } = options.processorOptions;
|
|
22
|
+
|
|
23
|
+
this._frameSamples = frameSamples;
|
|
24
|
+
this._direct = sampleRate === outputRate;
|
|
25
|
+
// входных сэмплов на один выходной
|
|
26
|
+
this._step = sampleRate / outputRate;
|
|
27
|
+
// позиция следующего выходного сэмпла во входе: 0 — первый сэмпл
|
|
28
|
+
// текущего кванта, -1 — последний сэмпл прошлого
|
|
29
|
+
this._phase = 0;
|
|
30
|
+
this._prev = 0;
|
|
31
|
+
this._lowpass = sampleRate > outputRate;
|
|
32
|
+
this._alpha =
|
|
33
|
+
1 - Math.exp((-2 * Math.PI * LOWPASS_RATIO * outputRate) / sampleRate);
|
|
34
|
+
this._lpState = 0;
|
|
35
|
+
this._lpReady = false;
|
|
36
|
+
this._frame = new Float32Array(frameSamples);
|
|
37
|
+
this._fill = 0;
|
|
38
|
+
this._active = false;
|
|
39
|
+
this._alive = true;
|
|
40
|
+
this._generation = 0;
|
|
41
|
+
this._scratch = new Float32Array(128);
|
|
42
|
+
|
|
43
|
+
// кадры уходят только в эфире: без него главный поток не получает ни
|
|
44
|
+
// одного сообщения
|
|
45
|
+
this.port.onmessage = ({ data }) => {
|
|
46
|
+
if (data.shutdown === true) {
|
|
47
|
+
this._alive = false;
|
|
48
|
+
this._active = false;
|
|
49
|
+
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
this._active = data.active === true;
|
|
54
|
+
this._generation = data.generation;
|
|
55
|
+
this._fill = 0;
|
|
56
|
+
this._phase = 0;
|
|
57
|
+
this._lpReady = false;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
_emit(value) {
|
|
62
|
+
this._frame[this._fill++] = value;
|
|
63
|
+
|
|
64
|
+
if (this._fill === this._frameSamples) {
|
|
65
|
+
const frame = this._frame;
|
|
66
|
+
|
|
67
|
+
this._frame = new Float32Array(this._frameSamples);
|
|
68
|
+
this._fill = 0;
|
|
69
|
+
this.port.postMessage({ generation: this._generation, samples: frame }, [
|
|
70
|
+
frame.buffer,
|
|
71
|
+
]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
process(inputs) {
|
|
76
|
+
if (!this._alive) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const input = inputs[0]?.[0];
|
|
81
|
+
|
|
82
|
+
if (!this._active || !input) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const count = input.length;
|
|
87
|
+
|
|
88
|
+
if (this._direct) {
|
|
89
|
+
for (let i = 0; i < count; i++) {
|
|
90
|
+
this._emit(input[i]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let samples = input;
|
|
97
|
+
|
|
98
|
+
if (this._lowpass) {
|
|
99
|
+
if (this._scratch.length !== count) {
|
|
100
|
+
this._scratch = new Float32Array(count);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
samples = this._scratch;
|
|
104
|
+
|
|
105
|
+
for (let i = 0; i < count; i++) {
|
|
106
|
+
if (!this._lpReady) {
|
|
107
|
+
this._lpState = input[i];
|
|
108
|
+
this._lpReady = true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
this._lpState += this._alpha * (input[i] - this._lpState);
|
|
112
|
+
samples[i] = this._lpState;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let phase = this._phase;
|
|
117
|
+
|
|
118
|
+
// выходной сэмпл между samples[i] и samples[i + 1]; пара через стык
|
|
119
|
+
// квантов (phase в [count - 1, count)) считается в следующем кванте
|
|
120
|
+
while (phase < count - 1) {
|
|
121
|
+
const index = Math.floor(phase);
|
|
122
|
+
const a = index < 0 ? this._prev : samples[index];
|
|
123
|
+
const b = samples[index + 1];
|
|
124
|
+
|
|
125
|
+
this._emit(a + (b - a) * (phase - index));
|
|
126
|
+
phase += this._step;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
this._phase = phase - count;
|
|
130
|
+
this._prev = samples[count - 1];
|
|
131
|
+
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
registerProcessor('vimp-voice-capture', VoiceCaptureProcessor);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {
|
|
2
|
+
VOICE_FRAME_MS,
|
|
3
|
+
VOICE_SAMPLE_RATE,
|
|
4
|
+
} from '../../config/voiceProtocol.js';
|
|
5
|
+
|
|
6
|
+
// Конфигурации кодека Opus — единый источник для поддержки, энкодера и
|
|
7
|
+
// декодера. FEC выключен: декодер WebCodecs не восстанавливает кадр из
|
|
8
|
+
// следующего пакета, а битрейт FEC съел бы. DTX выключен: детектор тишины
|
|
9
|
+
// потока (VoicePlayback) опирается на непрерывный поток пакетов.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Кандидаты конфигурации энкодера по убыванию предпочтения: с opus-блоком
|
|
13
|
+
* и без него (не каждый браузер знает OpusEncoderConfig).
|
|
14
|
+
* @param {number} bitrate - бит/с.
|
|
15
|
+
* @returns {Object[]}
|
|
16
|
+
*/
|
|
17
|
+
export function encoderConfigCandidates(bitrate) {
|
|
18
|
+
const base = {
|
|
19
|
+
codec: 'opus',
|
|
20
|
+
sampleRate: VOICE_SAMPLE_RATE,
|
|
21
|
+
numberOfChannels: 1,
|
|
22
|
+
bitrate,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return [
|
|
26
|
+
{
|
|
27
|
+
...base,
|
|
28
|
+
opus: {
|
|
29
|
+
frameDuration: VOICE_FRAME_MS * 1000,
|
|
30
|
+
complexity: 5,
|
|
31
|
+
usedtx: false,
|
|
32
|
+
useinbandfec: false,
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{ ...base },
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Конфигурация декодера.
|
|
41
|
+
* @returns {Object}
|
|
42
|
+
*/
|
|
43
|
+
export function decoderConfig() {
|
|
44
|
+
return {
|
|
45
|
+
codec: 'opus',
|
|
46
|
+
sampleRate: VOICE_SAMPLE_RATE,
|
|
47
|
+
numberOfChannels: 1,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const isNode = (node, ctx) =>
|
|
2
|
+
Boolean(node) && typeof node.connect === 'function' && node.context === ctx;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Выход звука голосовой связи поверх выхода движка: шина речи (через граф
|
|
6
|
+
* окраски игры) и шина сигналов → master gain Howler (общая громкость и mute
|
|
7
|
+
* движка действуют и на связь).
|
|
8
|
+
* @param {BaseAudioContext} ctx
|
|
9
|
+
* @param {AudioNode} destination - master gain Howler.
|
|
10
|
+
* @param {Object} [opts]
|
|
11
|
+
* @param {Function} [opts.voiceFilter] - ClientPlugin.hooks.voiceFilter.
|
|
12
|
+
* @param {Object} [opts.diagnostics] - { warn(code, details) }.
|
|
13
|
+
* @returns {{ speechInput: AudioNode, cueInput: AudioNode, dispose(): void }}
|
|
14
|
+
*/
|
|
15
|
+
export function createVoiceOutput(
|
|
16
|
+
ctx,
|
|
17
|
+
destination,
|
|
18
|
+
{ voiceFilter, diagnostics } = {},
|
|
19
|
+
) {
|
|
20
|
+
const speechInput = ctx.createGain();
|
|
21
|
+
const cueInput = ctx.createGain();
|
|
22
|
+
let graph = null;
|
|
23
|
+
|
|
24
|
+
cueInput.connect(destination);
|
|
25
|
+
|
|
26
|
+
if (typeof voiceFilter === 'function') {
|
|
27
|
+
try {
|
|
28
|
+
const answer = voiceFilter(ctx);
|
|
29
|
+
|
|
30
|
+
// null/undefined — игра окраску не хочет
|
|
31
|
+
if (answer !== null && answer !== undefined) {
|
|
32
|
+
if (isNode(answer.input, ctx) && isNode(answer.output, ctx)) {
|
|
33
|
+
graph = answer;
|
|
34
|
+
} else {
|
|
35
|
+
diagnostics?.warn('voice.filter', {
|
|
36
|
+
message:
|
|
37
|
+
'voiceFilter must return { input, output } nodes of the ctx',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {
|
|
42
|
+
diagnostics?.warn('voice.filter', { message: error?.message });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (graph) {
|
|
47
|
+
speechInput.connect(graph.input);
|
|
48
|
+
graph.output.connect(destination);
|
|
49
|
+
} else {
|
|
50
|
+
speechInput.connect(destination);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
speechInput,
|
|
55
|
+
cueInput,
|
|
56
|
+
dispose() {
|
|
57
|
+
for (const node of [speechInput, cueInput, graph?.output]) {
|
|
58
|
+
try {
|
|
59
|
+
node?.disconnect();
|
|
60
|
+
} catch {
|
|
61
|
+
// уже отключён
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
graph?.dispose?.();
|
|
67
|
+
} catch {
|
|
68
|
+
// сбой окраски при разборе не мешает остальному
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { decoderConfig, encoderConfigCandidates } from './opusConfig.js';
|
|
2
|
+
|
|
3
|
+
// isConfigSupported бросает на неизвестных полях конфигурации — это «нет»
|
|
4
|
+
async function isSupported(Codec, config) {
|
|
5
|
+
try {
|
|
6
|
+
const result = await Codec.isConfigSupported(config);
|
|
7
|
+
|
|
8
|
+
return result?.supported === true;
|
|
9
|
+
} catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Что умеет браузер. Захват требует защищённого контекста, getUserMedia,
|
|
16
|
+
* Web Audio движка (контекст Howler: на нём же сидит захват) с
|
|
17
|
+
* `audioWorklet` и AudioEncoder с поддержанной конфигурацией Opus;
|
|
18
|
+
* воспроизведение — Web Audio движка и AudioDecoder с поддержанной
|
|
19
|
+
* конфигурацией.
|
|
20
|
+
* @param {Object} env
|
|
21
|
+
* @param {Object} [env.globals] - globalThis.
|
|
22
|
+
* @param {number} env.bitrate - бит/с энкодера.
|
|
23
|
+
* @param {Function} env.getOutput - soundManager.getAudioOutput.
|
|
24
|
+
* @returns {Promise<{ capture: boolean, playback: boolean,
|
|
25
|
+
* encoderConfig: Object|null, reason: string|null }>}
|
|
26
|
+
* reason — первая причина отказа захвата: 'insecure' | 'noMediaDevices' |
|
|
27
|
+
* 'noWebAudio' | 'noWorklet' | 'noEncoder' | 'codec'.
|
|
28
|
+
*/
|
|
29
|
+
export async function detectVoiceSupport({
|
|
30
|
+
globals = globalThis,
|
|
31
|
+
bitrate,
|
|
32
|
+
getOutput,
|
|
33
|
+
}) {
|
|
34
|
+
const output = getOutput();
|
|
35
|
+
const hasDecoder =
|
|
36
|
+
typeof globals.AudioDecoder?.isConfigSupported === 'function';
|
|
37
|
+
const playback =
|
|
38
|
+
Boolean(output) &&
|
|
39
|
+
hasDecoder &&
|
|
40
|
+
(await isSupported(globals.AudioDecoder, decoderConfig()));
|
|
41
|
+
|
|
42
|
+
let reason = null;
|
|
43
|
+
let encoderConfig = null;
|
|
44
|
+
|
|
45
|
+
if (!globals.isSecureContext) {
|
|
46
|
+
reason = 'insecure';
|
|
47
|
+
} else if (
|
|
48
|
+
typeof globals.navigator?.mediaDevices?.getUserMedia !== 'function'
|
|
49
|
+
) {
|
|
50
|
+
reason = 'noMediaDevices';
|
|
51
|
+
} else if (!output) {
|
|
52
|
+
reason = 'noWebAudio';
|
|
53
|
+
} else if (!output.ctx.audioWorklet) {
|
|
54
|
+
reason = 'noWorklet';
|
|
55
|
+
} else if (typeof globals.AudioEncoder?.isConfigSupported !== 'function') {
|
|
56
|
+
reason = 'noEncoder';
|
|
57
|
+
} else {
|
|
58
|
+
for (const candidate of encoderConfigCandidates(bitrate)) {
|
|
59
|
+
if (await isSupported(globals.AudioEncoder, candidate)) {
|
|
60
|
+
encoderConfig = candidate;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!encoderConfig) {
|
|
66
|
+
reason = 'codec';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { capture: reason === null, playback, encoderConfig, reason };
|
|
71
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Сигналы связи (D11) и нейтральные тоны движка. Чистые функции синтеза:
|
|
2
|
+
// без файлов и случайности. Стиль игре даёт хук hooks.voiceSounds.
|
|
3
|
+
|
|
4
|
+
// ключи набора hooks.voiceSounds; порядок — порядок таблицы в доках
|
|
5
|
+
export const VOICE_CUES = Object.freeze([
|
|
6
|
+
'talkStart',
|
|
7
|
+
'talkEnd',
|
|
8
|
+
'busy',
|
|
9
|
+
'deny',
|
|
10
|
+
'incomingStart',
|
|
11
|
+
'incomingEnd',
|
|
12
|
+
'bed',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
// нейтральные сигналы движка: синусы с огибающей. У bed тона нет (тишина)
|
|
16
|
+
export const TONE_SPECS = Object.freeze({
|
|
17
|
+
talkStart: [{ hz: 660, ms: 50 }, { gapMs: 15 }, { hz: 990, ms: 70 }],
|
|
18
|
+
talkEnd: [{ hz: 990, ms: 50 }, { gapMs: 15 }, { hz: 660, ms: 70 }],
|
|
19
|
+
busy: [{ hz: 330, ms: 120 }, { gapMs: 80 }, { hz: 330, ms: 120 }],
|
|
20
|
+
deny: [{ hz: 330, ms: 120 }],
|
|
21
|
+
incomingStart: [{ hz: 784, ms: 90 }],
|
|
22
|
+
incomingEnd: [{ hz: 523, ms: 70 }],
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// пиковая амплитуда тона; края — косинусные фронты по TONE_EDGE_MS
|
|
26
|
+
export const TONE_AMPLITUDE = 0.25;
|
|
27
|
+
export const TONE_EDGE_MS = 5;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Длительность тона сигнала в мс (сумма сегментов).
|
|
31
|
+
* @param {string} cue
|
|
32
|
+
* @returns {number} 0 — у сигнала нет тона.
|
|
33
|
+
*/
|
|
34
|
+
export function toneDurationMs(cue) {
|
|
35
|
+
const spec = Object.hasOwn(TONE_SPECS, cue) ? TONE_SPECS[cue] : null;
|
|
36
|
+
|
|
37
|
+
return spec
|
|
38
|
+
? spec.reduce((sum, segment) => sum + (segment.ms ?? segment.gapMs), 0)
|
|
39
|
+
: 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Тон сигнала в частоте контекста. Каждый тональный сегмент — синус с
|
|
44
|
+
* фронтами TONE_EDGE_MS на обоих краях (щелчков нет), паузы — нули.
|
|
45
|
+
* @param {string} cue
|
|
46
|
+
* @param {number} sampleRate
|
|
47
|
+
* @returns {Float32Array|null} null — bed и неизвестное имя.
|
|
48
|
+
*/
|
|
49
|
+
export function synthesizeTone(cue, sampleRate) {
|
|
50
|
+
if (!Object.hasOwn(TONE_SPECS, cue)) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const lengths = TONE_SPECS[cue].map(segment =>
|
|
55
|
+
Math.round(((segment.ms ?? segment.gapMs) * sampleRate) / 1000),
|
|
56
|
+
);
|
|
57
|
+
const out = new Float32Array(
|
|
58
|
+
lengths.reduce((sum, length) => sum + length, 0),
|
|
59
|
+
);
|
|
60
|
+
let offset = 0;
|
|
61
|
+
|
|
62
|
+
TONE_SPECS[cue].forEach((segment, index) => {
|
|
63
|
+
const length = lengths[index];
|
|
64
|
+
|
|
65
|
+
if (segment.hz) {
|
|
66
|
+
const edge = Math.min(
|
|
67
|
+
Math.floor((TONE_EDGE_MS * sampleRate) / 1000),
|
|
68
|
+
Math.floor(length / 2),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
for (let i = 0; i < length; i++) {
|
|
72
|
+
const fromEnd = length - 1 - i;
|
|
73
|
+
const edgeDist = Math.min(i, fromEnd);
|
|
74
|
+
const envelope =
|
|
75
|
+
edgeDist >= edge
|
|
76
|
+
? 1
|
|
77
|
+
: 0.5 * (1 - Math.cos((Math.PI * edgeDist) / edge));
|
|
78
|
+
|
|
79
|
+
out[offset + i] =
|
|
80
|
+
TONE_AMPLITUDE *
|
|
81
|
+
envelope *
|
|
82
|
+
Math.sin((2 * Math.PI * segment.hz * i) / sampleRate);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
offset += length;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return out;
|
|
90
|
+
}
|
package/src/config/authClient.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// config/lobby.js — auth-сервис живёт на отдельном домене/порте, поэтому
|
|
3
3
|
// serviceUrl подставляется под конкретный деплой при сборке.
|
|
4
4
|
export default {
|
|
5
|
-
// базовый URL центрального auth
|
|
6
|
-
// из
|
|
5
|
+
// базовый URL центрального auth-сервиса. Дефолт — dev-порт
|
|
6
|
+
// из конфига auth-сервиса; в проде подставляется Vite'ом из
|
|
7
7
|
// VITE_AUTH_SERVICE_URL (см. Dockerfile — тот же VIMP_AUTH_SERVICE_URL,
|
|
8
8
|
// что и серверный master/main.js). Этот модуль импортируется и серверным
|
|
9
9
|
// master/main.js под чистым Node, где import.meta.env не определён —
|
|
@@ -14,7 +14,7 @@ export default {
|
|
|
14
14
|
'http://localhost:3010',
|
|
15
15
|
|
|
16
16
|
// issuer, которым central auth-сервис подписывает identity-токен; должен
|
|
17
|
-
// совпадать с
|
|
17
|
+
// совпадать с jwt.issuer из конфига auth-сервиса — хост сверяет
|
|
18
18
|
// claim iss токена с этим значением при проверке подписи по /jwks
|
|
19
19
|
issuer: 'vimp-auth',
|
|
20
20
|
|
|
@@ -21,6 +21,8 @@ export const clientServices = createRegistry('clientServices', [
|
|
|
21
21
|
{ value: 'accolades', since: 4 },
|
|
22
22
|
// журнал клиентских ошибок (lib/diagnostics.js): warn/capture партов
|
|
23
23
|
{ value: 'diagnostics', since: 4 },
|
|
24
|
+
// голосовая связь: кто сейчас в эфире (client/lib/voiceState.js)
|
|
25
|
+
{ value: 'voice', since: 4 },
|
|
24
26
|
]);
|
|
25
27
|
|
|
26
28
|
// плоский список имён — то, чем правило C4 проверяет componentDependencies
|
package/src/config/lobby.js
CHANGED
|
@@ -300,11 +300,11 @@ export default {
|
|
|
300
300
|
joinRetryWindowMs: 45000,
|
|
301
301
|
},
|
|
302
302
|
|
|
303
|
-
// приёмник выгрузок отладочного
|
|
303
|
+
// приёмник выгрузок отладочного контура:
|
|
304
304
|
// маршрут поднимается мастером только в dev, в проде вернёт 404
|
|
305
305
|
debugReportUrl: '/debug/report',
|
|
306
306
|
|
|
307
|
-
// журнал клиентских
|
|
307
|
+
// журнал клиентских ошибок: приём на том же боксе,
|
|
308
308
|
// что раздал страницу, — лобби-мастер или dedicated
|
|
309
309
|
clientReportUrl: '/client-reports',
|
|
310
310
|
|
|
@@ -350,9 +350,8 @@ export default {
|
|
|
350
350
|
myPlacementId: 'lobby-my-placement',
|
|
351
351
|
|
|
352
352
|
// футер: версия npm-пакета движка, запечённая в бандл при сборке
|
|
353
|
-
// (client/lib/engineVersion.js)
|
|
353
|
+
// (client/lib/engineVersion.js)
|
|
354
354
|
versionId: 'lobby-version',
|
|
355
|
-
linkId: 'lobby-link',
|
|
356
355
|
},
|
|
357
356
|
|
|
358
357
|
// реестр игр (master-game-registry, этап 4): заявка разработчика и панель
|
|
@@ -489,7 +488,7 @@ export default {
|
|
|
489
488
|
},
|
|
490
489
|
},
|
|
491
490
|
|
|
492
|
-
// журнал клиентских
|
|
491
|
+
// журнал клиентских ошибок: только админ.
|
|
493
492
|
// URL и id элементов — здесь, как у панели реестра игр (reports.pug)
|
|
494
493
|
clientReports: {
|
|
495
494
|
urls: {
|
package/src/config/opcodes.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Разметка бинарного snapshot-протокола (порт SHOT_DATA).
|
|
2
2
|
// Единый источник для хоста (упаковка) и клиента (распаковка) — обе стороны
|
|
3
|
-
// живут в Rust-ядре:
|
|
3
|
+
// живут в Rust-ядре: vimp-engine-core/src/snapshot.rs (pack) и
|
|
4
|
+
// vimp-engine-core/src/client/unpack.rs.
|
|
4
5
|
|
|
5
6
|
// версия контракта движок ↔ игра-плагин (GameManifest, HostPlugin,
|
|
6
7
|
// ClientPlugin, Wasm Host ABI — docs/{en,ru}/plugin-api.md).
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Дефолты и допустимые значения gameConfig.voice. Единственный источник для
|
|
2
|
+
// lib/voiceConfig.js (рантайм), правила контракта B11 (статическая проверка)
|
|
3
|
+
// и документации.
|
|
4
|
+
export const VOICE_DEFAULTS = Object.freeze({
|
|
5
|
+
enabled: false,
|
|
6
|
+
scope: 'team',
|
|
7
|
+
hotkey: 86, // V
|
|
8
|
+
maxDurationMs: 5000,
|
|
9
|
+
cooldownMs: 1500,
|
|
10
|
+
bitrate: 16000,
|
|
11
|
+
texts: Object.freeze({
|
|
12
|
+
busy: 'Channel busy',
|
|
13
|
+
cooldown: 'Stand by',
|
|
14
|
+
unavailable: 'Voice unavailable',
|
|
15
|
+
micBlocked: 'Microphone blocked',
|
|
16
|
+
unsupported: 'Voice not supported',
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// 'team' — канал на команду, 'global' — один на комнату
|
|
21
|
+
export const VOICE_SCOPES = Object.freeze(['team', 'global']);
|
|
22
|
+
|
|
23
|
+
// закрытые диапазоны [min, max]; значение вне — зажимается с предупреждением
|
|
24
|
+
export const VOICE_RANGES = Object.freeze({
|
|
25
|
+
maxDurationMs: [5000, 15000],
|
|
26
|
+
cooldownMs: [0, 10000],
|
|
27
|
+
bitrate: [6000, 32000],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// keyCode — целое 1..255
|
|
31
|
+
export const VOICE_HOTKEY_RANGE = Object.freeze([1, 255]);
|
|
32
|
+
|
|
33
|
+
// потолок длины подписи HUD
|
|
34
|
+
export const VOICE_TEXT_MAX_LENGTH = 60;
|
|
35
|
+
|
|
36
|
+
export const VOICE_KEYS = Object.freeze(Object.keys(VOICE_DEFAULTS));
|
|
37
|
+
export const VOICE_TEXT_KEYS = Object.freeze(Object.keys(VOICE_DEFAULTS.texts));
|
|
38
|
+
|
|
39
|
+
// все пути, которые игра может написать: раздел gameConfigFields снимка
|
|
40
|
+
// поверхности (lib/gameConfigView.js → KNOWN_GAME_CONFIG_PATHS)
|
|
41
|
+
export const VOICE_CONFIG_PATHS = Object.freeze([
|
|
42
|
+
...VOICE_KEYS.filter(key => key !== 'texts').map(key => `voice.${key}`),
|
|
43
|
+
...VOICE_TEXT_KEYS.map(key => `voice.texts.${key}`),
|
|
44
|
+
]);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Протокол голосовой связи: коды сообщений и константы,
|
|
2
|
+
// общие для хоста, ретранслятора и клиента.
|
|
3
|
+
//
|
|
4
|
+
// VOICE_DATA (server → client), кортежи — собирает и разбирает
|
|
5
|
+
// lib/voiceMessages.js:
|
|
6
|
+
// GRANT [0, talkId, maxDurationMs] запросившему
|
|
7
|
+
// DENY [1, reason, retryAfterMs] запросившему
|
|
8
|
+
// END [2, talkId, reason] говорящему
|
|
9
|
+
// START [3, speakerId, talkId, name, teamId, chatColor]
|
|
10
|
+
// слушателям канала + говорящему
|
|
11
|
+
// STOP [4, speakerId, talkId] тем, кому ушёл START
|
|
12
|
+
//
|
|
13
|
+
// Голосовой пакет (lib/voicePacket.js):
|
|
14
|
+
// байт 0 порт (client → host 13 / host → client 22)
|
|
15
|
+
// байт 1 версия формата
|
|
16
|
+
// байт 2 id говорящего (клиент пишет 0, ретранслятор ставит авторитетный)
|
|
17
|
+
// байт 3 talkId (из GRANT)
|
|
18
|
+
// байты 4–5 seq, u16 big-endian, от 0 в каждом эфире
|
|
19
|
+
// байты 6… Opus-пакет
|
|
20
|
+
|
|
21
|
+
export const VOICE_MSG = Object.freeze({
|
|
22
|
+
GRANT: 0,
|
|
23
|
+
DENY: 1,
|
|
24
|
+
END: 2,
|
|
25
|
+
START: 3,
|
|
26
|
+
STOP: 4,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// операции VOICE_FLOOR (client → server): [1] запрос, [0, talkId] отпускание
|
|
30
|
+
export const VOICE_FLOOR_OP = Object.freeze({ RELEASE: 0, REQUEST: 1 });
|
|
31
|
+
|
|
32
|
+
export const VOICE_DENY = Object.freeze({
|
|
33
|
+
BUSY: 0,
|
|
34
|
+
COOLDOWN: 1,
|
|
35
|
+
FORBIDDEN: 2,
|
|
36
|
+
UNAVAILABLE: 3,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export const VOICE_END = Object.freeze({ RELEASED: 0, TIMEOUT: 1, REVOKED: 2 });
|
|
40
|
+
|
|
41
|
+
// байт 1 пакета
|
|
42
|
+
export const VOICE_PACKET_VERSION = 1;
|
|
43
|
+
// длина заголовка пакета
|
|
44
|
+
export const VOICE_HEADER_BYTES = 6;
|
|
45
|
+
// потолок пакета (32 кбит/с × 20 мс = 80 Б, запас кратный)
|
|
46
|
+
export const VOICE_MAX_PACKET_BYTES = 512;
|
|
47
|
+
// id говорящего на проводе — u8
|
|
48
|
+
export const VOICE_MAX_SPEAKER_ID = 255;
|
|
49
|
+
// talkId на проводе — u8 без нуля: 1..VOICE_MAX_TALK_ID
|
|
50
|
+
export const VOICE_MAX_TALK_ID = 255;
|
|
51
|
+
// ник в START обрезается до этой длины (ник ≤ 15 + суффикс #N)
|
|
52
|
+
export const VOICE_NAME_MAX_LENGTH = 32;
|
|
53
|
+
// частота захвата и кодека
|
|
54
|
+
export const VOICE_SAMPLE_RATE = 48000;
|
|
55
|
+
// длительность кадра Opus
|
|
56
|
+
export const VOICE_FRAME_MS = 20;
|
|
57
|
+
// хост обрывает эфир на maxDurationMs + запас (клиент — ровно на maxDurationMs)
|
|
58
|
+
export const VOICE_HOST_GRACE_MS = 300;
|
|
59
|
+
// запросы эфира чаще этого интервала получают отказ cooldown
|
|
60
|
+
export const VOICE_REQUEST_MIN_INTERVAL_MS = 150;
|
|
61
|
+
// ведро токенов ретранслятора на говорящего
|
|
62
|
+
export const VOICE_RELAY_RATE = Object.freeze({
|
|
63
|
+
packetsPerSecond: 60,
|
|
64
|
+
burst: 20,
|
|
65
|
+
});
|
|
66
|
+
// байтовое ведро ретранслятора на говорящего: около двух честных потоков на
|
|
67
|
+
// потолке битрейта (32 кбит/с ≈ 4300 Б/с с заголовками); без него пакетное
|
|
68
|
+
// ведро пропускало бы по 512 Б × 60 пакетов/с каждому слушателю
|
|
69
|
+
export const VOICE_RELAY_BYTE_RATE = Object.freeze({
|
|
70
|
+
bytesPerSecond: 8192,
|
|
71
|
+
burst: 4096,
|
|
72
|
+
});
|
|
73
|
+
// порог bufferedAmount, выше которого пакет не шлётся
|
|
74
|
+
export const VOICE_BACKPRESSURE_BYTES = 65536;
|
|
75
|
+
// индикатор и звук гаснут сами через maxDurationMs + это время без STOP
|
|
76
|
+
export const VOICE_LISTENER_SAFETY_MS = 2000;
|
|
77
|
+
// «тёплый» микрофон: после последнего эфира закрывается через это время
|
|
78
|
+
export const VOICE_MIC_IDLE_RELEASE_MS = 30000;
|
|
79
|
+
// нет ответа на запрос эфира — запрос считается проваленным
|
|
80
|
+
export const VOICE_REQUEST_TIMEOUT_MS = 2000;
|
package/src/config/wsports.js
CHANGED
|
@@ -34,6 +34,12 @@ export default {
|
|
|
34
34
|
// { resumeKey, gameId } и ответ на RESUME_REQUEST
|
|
35
35
|
SESSION_DATA: 19,
|
|
36
36
|
RESUME_RESULT: 20,
|
|
37
|
+
// голосовая связь: управление эфиром —
|
|
38
|
+
// GRANT/DENY/END/START/STOP (lib/voiceMessages.js)
|
|
39
|
+
VOICE_DATA: 21,
|
|
40
|
+
// голосовая связь: первый байт бинарного голосового пакета хост → клиент
|
|
41
|
+
// (lib/voicePacket.js); JSON по нему не ходит
|
|
42
|
+
VOICE_AUDIO: 22,
|
|
37
43
|
},
|
|
38
44
|
// порты получения данных от клиента
|
|
39
45
|
client: {
|
|
@@ -54,5 +60,11 @@ export default {
|
|
|
54
60
|
// rank/state его токеном — без порта долгий матч писал бы истёкшим.
|
|
55
61
|
// Прежний хост порт игнорирует
|
|
56
62
|
TOKEN_REFRESH: 11,
|
|
63
|
+
// голосовая связь: [1] — запрос эфира, [0, talkId] — отпускание
|
|
64
|
+
VOICE_FLOOR: 12,
|
|
65
|
+
// голосовая связь: первый байт бинарного голосового пакета клиент → хост.
|
|
66
|
+
// Порт-машина его не разбирает никогда — пакет перехватывает транспорт и
|
|
67
|
+
// отдаёт ретранслятору (lib/VoiceRelay.js)
|
|
68
|
+
VOICE_AUDIO: 13,
|
|
57
69
|
},
|
|
58
70
|
};
|
|
@@ -14,7 +14,7 @@ import { createGameConfigView } from '../../lib/gameConfigView.js';
|
|
|
14
14
|
// модули; исходники читаются только там, где проверяется буква кода
|
|
15
15
|
// (engineApi литералом или импортом).
|
|
16
16
|
|
|
17
|
-
// каталог движка
|
|
17
|
+
// каталог пакета движка — из него берётся версия крейта, с
|
|
18
18
|
// которой сверяется пин игры. core/Cargo.toml числится в "files"
|
|
19
19
|
// package.json именно ради этого: иначе в установке из npm версии крейта
|
|
20
20
|
// нет, и правило A5 проверяло бы пин только внутри монорепозитория
|
|
@@ -170,7 +170,7 @@ async function readEngineCoreVersion() {
|
|
|
170
170
|
// версия пакета в секции [package] — первый `version = "…"` до следующей
|
|
171
171
|
// секции; TOML-парсера в зависимостях движка нет, а формат здесь наш.
|
|
172
172
|
//
|
|
173
|
-
// Копия живёт в
|
|
173
|
+
// Копия живёт в create-vimp-game (src/versions.js): скаффолдер
|
|
174
174
|
// ставится через `npm create` и обязан работать без движка в зависимостях.
|
|
175
175
|
// Разбор обязан совпадать — правьте обе или ни одной (см. тест
|
|
176
176
|
// tests/scaffold/versions.test.js).
|