vimp-engine 0.38.3 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/src/client/InputListener.js +3 -0
  3. package/src/client/SoundManager.js +13 -0
  4. package/src/client/components/controller/Controls.js +10 -0
  5. package/src/client/components/controller/Voice.js +31 -0
  6. package/src/client/components/model/Controls.js +64 -0
  7. package/src/client/components/model/Voice.js +96 -0
  8. package/src/client/components/model/Vote.js +2 -1
  9. package/src/client/components/view/Voice.js +172 -0
  10. package/src/client/lib/voiceState.js +78 -0
  11. package/src/client/main.js +117 -0
  12. package/src/client/network/HostConnectionManager.js +46 -5
  13. package/src/client/network/HostController.js +43 -0
  14. package/src/client/network/LoopbackTransport.js +14 -0
  15. package/src/client/network/SessionSupervisor.js +24 -0
  16. package/src/client/network/WebRtcManager.js +45 -3
  17. package/src/client/style.css +146 -3
  18. package/src/client/voice/JitterBuffer.js +137 -0
  19. package/src/client/voice/MicCapture.js +307 -0
  20. package/src/client/voice/PttMachine.js +369 -0
  21. package/src/client/voice/VoiceClient.js +336 -0
  22. package/src/client/voice/VoiceCues.js +295 -0
  23. package/src/client/voice/VoiceEncoder.js +174 -0
  24. package/src/client/voice/VoicePlayback.js +558 -0
  25. package/src/client/voice/audioData.js +35 -0
  26. package/src/client/voice/captureProcessor.worklet.js +136 -0
  27. package/src/client/voice/opusConfig.js +49 -0
  28. package/src/client/voice/voiceOutput.js +72 -0
  29. package/src/client/voice/voiceSupport.js +71 -0
  30. package/src/client/voice/voiceTones.js +90 -0
  31. package/src/config/clientServices.js +2 -0
  32. package/src/config/voiceDefaults.js +44 -0
  33. package/src/config/voiceProtocol.js +80 -0
  34. package/src/config/voteKeys.js +4 -0
  35. package/src/config/wsports.js +12 -0
  36. package/src/devtools/contract/rules/b11-voice-config.js +34 -0
  37. package/src/devtools/contract/rules/c12-voice-hotkey.js +70 -0
  38. package/src/devtools/contract/rules/index.js +4 -0
  39. package/src/host/HostGame.js +58 -0
  40. package/src/host/PortMachine.js +19 -3
  41. package/src/host/host.worker.js +7 -0
  42. package/src/host/meta/SocketManager.js +6 -0
  43. package/src/host/meta/modules/voice/Voice.js +430 -0
  44. package/src/host/meta/modules/voice/channels.js +58 -0
  45. package/src/host/meta/modules/voice/index.js +1 -0
  46. package/src/lib/VoiceRelay.js +200 -0
  47. package/src/lib/buildClientConfig.js +7 -0
  48. package/src/lib/capabilities.js +4 -0
  49. package/src/lib/createHostRuntime.js +8 -0
  50. package/src/lib/gameConfigView.js +11 -1
  51. package/src/lib/voiceConfig.js +136 -0
  52. package/src/lib/voiceMessages.js +172 -0
  53. package/src/lib/voicePacket.js +142 -0
@@ -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
+ }
@@ -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
@@ -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;
@@ -0,0 +1,4 @@
1
+ // keyCode цифр 0..9: клавиши ответа в окне голосования. Общий источник для
2
+ // Vote (читает ответ) и правила контракта C12 (клавиша связи не должна их
3
+ // занимать)
4
+ export const VOTE_ANSWER_KEYS = Object.freeze([48, 57]);
@@ -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
  };
@@ -0,0 +1,34 @@
1
+ import { inspectVoiceConfig } from '../../../lib/voiceConfig.js';
2
+ import { WARN, skip, verdict } from '../result.js';
3
+
4
+ // Блок voice необязателен: без него связь выключена. Но объявленный блок с
5
+ // опечаткой — тихий откат на дефолт (или зажим числа). Разбор берётся из
6
+ // lib/voiceConfig.js — того же модуля, что читает рантайм, иначе правило и
7
+ // движок разошлись бы. Хуки voiceSounds/voiceFilter статически не
8
+ // проверяются: их ответ виден только в браузере, и его проверяет рантайм.
9
+ export default {
10
+ id: 'B11',
11
+ name: 'voiceConfig',
12
+ level: WARN,
13
+ title: 'the voice block is well-formed',
14
+
15
+ check(ctx) {
16
+ if (!ctx.gameConfig) {
17
+ return skip('no gameConfig');
18
+ }
19
+
20
+ if (ctx.gameConfig.voice === undefined || ctx.gameConfig.voice === null) {
21
+ return skip('no voice block — voice is off');
22
+ }
23
+
24
+ const { problems } = inspectVoiceConfig(ctx.gameConfig.voice);
25
+
26
+ // забытый enabled: блок есть, связи нет, и ни одно правило не скажет.
27
+ // enabled: false — осознанное выключение, не нарушение
28
+ if (ctx.gameConfig.voice.enabled === undefined) {
29
+ problems.push('voice block is declared but voice.enabled is not set');
30
+ }
31
+
32
+ return verdict(problems);
33
+ },
34
+ };