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
@@ -51,6 +51,10 @@ export const ENGINE_CAPABILITIES = createRegistry('engine-capabilities', [
51
51
  // Писать в `requires` не нужно: старый движок флаг просто игнорирует, и
52
52
  // игра на нём мигрирует мягко
53
53
  { value: 'host.migration', since: '0.36.0' },
54
+ // голосовая связь (gameConfig.voice, порты VOICE_*, сервис пула `voice`,
55
+ // хуки `hooks.voiceSounds`/`hooks.voiceFilter`); опциональна — игра не
56
+ // пишет её в `requires`
57
+ { value: 'voice', since: '0.39.0' },
54
58
  ]);
55
59
 
56
60
  // плоский список имён — то, чем правило контракта B2 проверяет `requires`
@@ -29,6 +29,9 @@ import HostGame from '../host/HostGame.js';
29
29
  * @param {Function} [options.overrideGameConfig] - Правка собранного конфига
30
30
  * игры перед созданием ядра; в проде не задаётся, нужна отладочному
31
31
  * прогону (сценарий подкручивает networkSendRate и таймеры).
32
+ * @param {boolean} [options.voiceRelay] - У контура есть ретранслятор голоса
33
+ * на транспортном уровне (браузерный хост лобби); без него связь
34
+ * выключается, даже если игра её объявила (standalone, headless, dedicated).
32
35
  * @returns {Promise<Object>} { hostPlugin, game, seed, core, clientCfg,
33
36
  * socketManager, host }.
34
37
  */
@@ -38,6 +41,7 @@ export async function createHostRuntime(room, options = {}) {
38
41
  createSocketManager = defaultCreateSocketManager,
39
42
  hostOptions = {},
40
43
  overrideGameConfig = null,
44
+ voiceRelay = false,
41
45
  } = options;
42
46
 
43
47
  const hostPlugin = await loadHostPlugin(room);
@@ -52,6 +56,10 @@ export async function createHostRuntime(room, options = {}) {
52
56
  overrideGameConfig(game);
53
57
  }
54
58
 
59
+ if (!voiceRelay && game.voice?.enabled) {
60
+ game.voice = Object.freeze({ ...game.voice, enabled: false });
61
+ }
62
+
55
63
  // seed уезжает наружу вызывающему: без него прогон матча невоспроизводим
56
64
  // (реплей задаёт room.seed и получает тот же мир)
57
65
  const seed =
@@ -1,4 +1,6 @@
1
1
  import hostDefaults from '../config/hostDefaults.js';
2
+ import { VOICE_CONFIG_PATHS } from '../config/voiceDefaults.js';
3
+ import { normalizeVoiceConfig } from './voiceConfig.js';
2
4
 
3
5
  // Единственная точка чтения HostPlugin.gameConfig движком (этап 2 плана
4
6
  // plugin-forward-compat). До неё движок разыменовывал конфиг игры россыпью
@@ -77,7 +79,11 @@ const FIELDS = {
77
79
  // те, за которые он подставляет умолчание. Раздел слепка поверхности
78
80
  // (contract/surface.json → gameConfigFields): исчезнувший отсюда путь —
79
81
  // нарушение И1, потому что игра могла его написать.
80
- export const KNOWN_GAME_CONFIG_PATHS = Object.keys(FIELDS);
82
+ // Пути связи живут в config/voiceDefaults.js.
83
+ export const KNOWN_GAME_CONFIG_PATHS = [
84
+ ...Object.keys(FIELDS),
85
+ ...VOICE_CONFIG_PATHS,
86
+ ];
81
87
 
82
88
  // ЗАМОРОЖЕНО (И2). Этот список может только СОКРАЩАТЬСЯ.
83
89
  // Добавление сюда отвергнет все ранее опубликованные игры — вместо этого
@@ -182,6 +188,10 @@ export function createGameConfigView(gameConfig, gameId = 'unknown') {
182
188
  );
183
189
  }
184
190
 
191
+ // блок связи нормализуется целиком (а не по путям FIELDS): неверное
192
+ // значение тоже должно получить дефолт, а не только отсутствующее
193
+ view.voice = normalizeVoiceConfig(source.voice, { gameId });
194
+
185
195
  assertConsistent(view, source, gameId);
186
196
 
187
197
  return Object.freeze(view);
@@ -0,0 +1,136 @@
1
+ import {
2
+ VOICE_DEFAULTS,
3
+ VOICE_HOTKEY_RANGE,
4
+ VOICE_KEYS,
5
+ VOICE_RANGES,
6
+ VOICE_SCOPES,
7
+ VOICE_TEXT_KEYS,
8
+ VOICE_TEXT_MAX_LENGTH,
9
+ } from '../config/voiceDefaults.js';
10
+
11
+ const isPlainObject = value =>
12
+ value !== null && typeof value === 'object' && !Array.isArray(value);
13
+
14
+ /**
15
+ * Разбирает блок gameConfig.voice. Конфиг не отвергается никогда: плохое
16
+ * значение заменяется дефолтом (числа вне диапазона — зажимом) и попадает в
17
+ * problems. Одна функция на два потребителя: рантайм предупреждает, правило
18
+ * контракта B11 отчитывается.
19
+ * @param {*} raw - Что написала игра (undefined/null — блока нет).
20
+ * @returns {{ config: Object, problems: string[] }} config — полный блок
21
+ * (все ключи, вложенный texts); problems — описания на английском.
22
+ */
23
+ export function inspectVoiceConfig(raw) {
24
+ const problems = [];
25
+ const config = { ...VOICE_DEFAULTS, texts: { ...VOICE_DEFAULTS.texts } };
26
+
27
+ if (raw === undefined || raw === null) {
28
+ return { config, problems };
29
+ }
30
+
31
+ if (!isPlainObject(raw)) {
32
+ problems.push('voice must be a plain object');
33
+
34
+ return { config, problems };
35
+ }
36
+
37
+ for (const key of Object.keys(raw)) {
38
+ if (!VOICE_KEYS.includes(key)) {
39
+ problems.push(`unknown key "voice.${key}"`);
40
+ }
41
+ }
42
+
43
+ if (raw.enabled !== undefined) {
44
+ if (typeof raw.enabled === 'boolean') {
45
+ config.enabled = raw.enabled;
46
+ } else {
47
+ problems.push('voice.enabled must be a boolean');
48
+ }
49
+ }
50
+
51
+ if (raw.scope !== undefined) {
52
+ if (VOICE_SCOPES.includes(raw.scope)) {
53
+ config.scope = raw.scope;
54
+ } else {
55
+ problems.push(`voice.scope must be one of ${VOICE_SCOPES.join(', ')}`);
56
+ }
57
+ }
58
+
59
+ if (raw.hotkey !== undefined) {
60
+ const [min, max] = VOICE_HOTKEY_RANGE;
61
+
62
+ if (
63
+ Number.isInteger(raw.hotkey) &&
64
+ raw.hotkey >= min &&
65
+ raw.hotkey <= max
66
+ ) {
67
+ config.hotkey = raw.hotkey;
68
+ } else {
69
+ problems.push(`voice.hotkey must be an integer keyCode ${min}..${max}`);
70
+ }
71
+ }
72
+
73
+ for (const [key, [min, max]] of Object.entries(VOICE_RANGES)) {
74
+ const value = raw[key];
75
+
76
+ if (value === undefined) {
77
+ continue;
78
+ }
79
+
80
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
81
+ problems.push(`voice.${key} must be a finite number`);
82
+ } else if (value < min || value > max) {
83
+ config[key] = Math.min(max, Math.max(min, value));
84
+ problems.push(`voice.${key} must be within ${min}..${max}, clamped`);
85
+ } else {
86
+ config[key] = value;
87
+ }
88
+ }
89
+
90
+ if (raw.texts !== undefined) {
91
+ if (!isPlainObject(raw.texts)) {
92
+ problems.push('voice.texts must be a plain object');
93
+ } else {
94
+ for (const [key, value] of Object.entries(raw.texts)) {
95
+ if (!VOICE_TEXT_KEYS.includes(key)) {
96
+ problems.push(`unknown key "voice.texts.${key}"`);
97
+ } else if (
98
+ typeof value !== 'string' ||
99
+ value.length > VOICE_TEXT_MAX_LENGTH
100
+ ) {
101
+ problems.push(
102
+ `voice.texts.${key} must be a string up to ` +
103
+ `${VOICE_TEXT_MAX_LENGTH} characters`,
104
+ );
105
+ } else {
106
+ config.texts[key] = value;
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ return { config, problems };
113
+ }
114
+
115
+ /**
116
+ * Рантайм-обёртка: каждая проблема уходит в warn.
117
+ * @param {*} raw - gameConfig.voice как есть.
118
+ * @param {Object} [options]
119
+ * @param {string} [options.gameId]
120
+ * @param {Function} [options.warn]
121
+ * @returns {Object} Замороженный полный блок (texts тоже заморожен).
122
+ */
123
+ export function normalizeVoiceConfig(
124
+ raw,
125
+ { gameId = 'unknown', warn = console.warn } = {},
126
+ ) {
127
+ const { config, problems } = inspectVoiceConfig(raw);
128
+
129
+ for (const problem of problems) {
130
+ warn(`game "${gameId}": voice: ${problem}`);
131
+ }
132
+
133
+ config.texts = Object.freeze(config.texts);
134
+
135
+ return Object.freeze(config);
136
+ }
@@ -0,0 +1,172 @@
1
+ import {
2
+ VOICE_DENY,
3
+ VOICE_END,
4
+ VOICE_FLOOR_OP,
5
+ VOICE_MAX_TALK_ID,
6
+ VOICE_MSG,
7
+ VOICE_NAME_MAX_LENGTH,
8
+ } from '../config/voiceProtocol.js';
9
+ import { VOICE_RANGES } from '../config/voiceDefaults.js';
10
+ import { isVoiceSpeakerId } from './voicePacket.js';
11
+
12
+ // Единственное место, где описана раскладка кортежей VOICE_DATA и
13
+ // VOICE_FLOOR: хост собирает ими, клиент и хост разбирают. Изоморфный.
14
+
15
+ const COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
16
+ const DENY_REASONS = new Set(Object.values(VOICE_DENY));
17
+ const END_REASONS = new Set(Object.values(VOICE_END));
18
+
19
+ const isTalkId = value =>
20
+ Number.isInteger(value) && value >= 1 && value <= VOICE_MAX_TALK_ID;
21
+
22
+ const clampMs = (value, max) => Math.min(max, Math.max(0, Math.round(value)));
23
+
24
+ // хост → клиент (собирает модуль Voice)
25
+ export const grantMessage = (talkId, maxDurationMs) => [
26
+ VOICE_MSG.GRANT,
27
+ talkId,
28
+ maxDurationMs,
29
+ ];
30
+
31
+ export const denyMessage = (reason, retryAfterMs) => [
32
+ VOICE_MSG.DENY,
33
+ reason,
34
+ retryAfterMs,
35
+ ];
36
+
37
+ export const endMessage = (talkId, reason) => [VOICE_MSG.END, talkId, reason];
38
+
39
+ export const startMessage = ({
40
+ speakerId,
41
+ talkId,
42
+ name,
43
+ teamId,
44
+ chatColor,
45
+ }) => [
46
+ VOICE_MSG.START,
47
+ speakerId,
48
+ talkId,
49
+ name,
50
+ teamId ?? null,
51
+ chatColor ?? null,
52
+ ];
53
+
54
+ export const stopMessage = (speakerId, talkId) => [
55
+ VOICE_MSG.STOP,
56
+ speakerId,
57
+ talkId,
58
+ ];
59
+
60
+ // клиент → хост (собирает VoiceClient)
61
+ export const floorRequestMessage = () => [VOICE_FLOOR_OP.REQUEST];
62
+
63
+ export const floorReleaseMessage = talkId => [VOICE_FLOOR_OP.RELEASE, talkId];
64
+
65
+ /**
66
+ * Разбор VOICE_DATA на клиенте. Хост в P2P — браузер другого игрока:
67
+ * принимается только кортеж, прошедший все проверки.
68
+ * @param {*} data
69
+ * @returns {{ type: 'grant', talkId: number, maxDurationMs: number }
70
+ * | { type: 'deny', reason: number, retryAfterMs: number }
71
+ * | { type: 'end', talkId: number, reason: number }
72
+ * | { type: 'start', speakerId: number, talkId: number, name: string,
73
+ * teamId: number|null, color: string|null }
74
+ * | { type: 'stop', speakerId: number, talkId: number }|null}
75
+ */
76
+ export function parseVoiceMessage(data) {
77
+ if (!Array.isArray(data)) {
78
+ return null;
79
+ }
80
+
81
+ switch (data[0]) {
82
+ case VOICE_MSG.GRANT: {
83
+ const [, talkId, maxDurationMs] = data;
84
+
85
+ if (!isTalkId(talkId) || !Number.isFinite(maxDurationMs)) {
86
+ return null;
87
+ }
88
+
89
+ return {
90
+ type: 'grant',
91
+ talkId,
92
+ maxDurationMs: clampMs(maxDurationMs, VOICE_RANGES.maxDurationMs[1]),
93
+ };
94
+ }
95
+
96
+ case VOICE_MSG.DENY: {
97
+ const [, reason, retryAfterMs] = data;
98
+
99
+ if (!DENY_REASONS.has(reason) || !Number.isFinite(retryAfterMs)) {
100
+ return null;
101
+ }
102
+
103
+ return {
104
+ type: 'deny',
105
+ reason,
106
+ retryAfterMs: clampMs(retryAfterMs, VOICE_RANGES.cooldownMs[1]),
107
+ };
108
+ }
109
+
110
+ case VOICE_MSG.END: {
111
+ const [, talkId, reason] = data;
112
+
113
+ if (!isTalkId(talkId) || !END_REASONS.has(reason)) {
114
+ return null;
115
+ }
116
+
117
+ return { type: 'end', talkId, reason };
118
+ }
119
+
120
+ case VOICE_MSG.START: {
121
+ const [, speakerId, talkId, name, teamId, color] = data;
122
+
123
+ if (!isVoiceSpeakerId(speakerId) || !isTalkId(talkId)) {
124
+ return null;
125
+ }
126
+
127
+ return {
128
+ type: 'start',
129
+ speakerId,
130
+ talkId,
131
+ name:
132
+ typeof name === 'string' ? name.slice(0, VOICE_NAME_MAX_LENGTH) : '',
133
+ teamId: Number.isInteger(teamId) ? teamId : null,
134
+ color: typeof color === 'string' && COLOR_RE.test(color) ? color : null,
135
+ };
136
+ }
137
+
138
+ case VOICE_MSG.STOP: {
139
+ const [, speakerId, talkId] = data;
140
+
141
+ if (!isVoiceSpeakerId(speakerId) || !isTalkId(talkId)) {
142
+ return null;
143
+ }
144
+
145
+ return { type: 'stop', speakerId, talkId };
146
+ }
147
+
148
+ default:
149
+ return null;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Разбор VOICE_FLOOR на хосте.
155
+ * @param {*} data
156
+ * @returns {{ op: 'request' } | { op: 'release', talkId: number }|null}
157
+ */
158
+ export function parseFloorMessage(data) {
159
+ if (!Array.isArray(data)) {
160
+ return null;
161
+ }
162
+
163
+ if (data[0] === VOICE_FLOOR_OP.REQUEST) {
164
+ return { op: 'request' };
165
+ }
166
+
167
+ if (data[0] === VOICE_FLOOR_OP.RELEASE && isTalkId(data[1])) {
168
+ return { op: 'release', talkId: data[1] };
169
+ }
170
+
171
+ return null;
172
+ }
@@ -0,0 +1,142 @@
1
+ import wsports from '../config/wsports.js';
2
+ import {
3
+ VOICE_HEADER_BYTES,
4
+ VOICE_MAX_PACKET_BYTES,
5
+ VOICE_MAX_SPEAKER_ID,
6
+ VOICE_PACKET_VERSION,
7
+ } from '../config/voiceProtocol.js';
8
+
9
+ // Кодек голосового пакета (раскладка — config/voiceProtocol.js). Изоморфный:
10
+ // им пользуются клиент и ретранслятор вкладки хоста (dedicated голоса не имеет).
11
+
12
+ const isByte = value => Number.isInteger(value) && value >= 0 && value <= 0xff;
13
+
14
+ /**
15
+ * id говорящего на проводе: целое 0..VOICE_MAX_SPEAKER_ID.
16
+ * @param {*} value
17
+ * @returns {boolean}
18
+ */
19
+ export function isVoiceSpeakerId(value) {
20
+ return Number.isInteger(value) && value >= 0 && value <= VOICE_MAX_SPEAKER_ID;
21
+ }
22
+
23
+ /**
24
+ * Нормализует вход к Uint8Array без копии: ArrayBuffer, любой
25
+ * ArrayBufferView (в т.ч. Node Buffer — он и есть Uint8Array).
26
+ * @param {*} data
27
+ * @returns {Uint8Array|null} null — не бинарные данные.
28
+ */
29
+ export function toBytes(data) {
30
+ if (data instanceof Uint8Array) {
31
+ return data;
32
+ }
33
+
34
+ if (data instanceof ArrayBuffer) {
35
+ return new Uint8Array(data);
36
+ }
37
+
38
+ if (ArrayBuffer.isView(data)) {
39
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
40
+ }
41
+
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * Собирает пакет: новый Uint8Array(VOICE_HEADER_BYTES + payload.length).
47
+ * speakerId — 0 у клиента. seq обрезается до u16.
48
+ * @param {Object} packet
49
+ * @param {number} packet.port - Байт 0 (wsports.client/server.VOICE_AUDIO).
50
+ * @param {number} [packet.speakerId]
51
+ * @param {number} packet.talkId
52
+ * @param {number} packet.seq
53
+ * @param {Uint8Array} packet.payload - Opus-пакет.
54
+ * @returns {Uint8Array}
55
+ * @throws {RangeError} port/speakerId/talkId не целые 0..255.
56
+ */
57
+ export function encodeVoicePacket({
58
+ port,
59
+ speakerId = 0,
60
+ talkId,
61
+ seq,
62
+ payload,
63
+ }) {
64
+ if (!isByte(port) || !isByte(speakerId) || !isByte(talkId)) {
65
+ throw new RangeError('voice packet: port, speakerId, talkId must be u8');
66
+ }
67
+
68
+ const bytes = new Uint8Array(VOICE_HEADER_BYTES + payload.length);
69
+
70
+ bytes[0] = port;
71
+ bytes[1] = VOICE_PACKET_VERSION;
72
+ bytes[2] = speakerId;
73
+ bytes[3] = talkId;
74
+
75
+ new DataView(bytes.buffer).setUint16(4, seq & 0xffff);
76
+ bytes.set(payload, VOICE_HEADER_BYTES);
77
+
78
+ return bytes;
79
+ }
80
+
81
+ /**
82
+ * Читает и проверяет заголовок: длина в [VOICE_HEADER_BYTES + 1,
83
+ * VOICE_MAX_PACKET_BYTES], версия === VOICE_PACKET_VERSION.
84
+ * @param {Uint8Array} bytes
85
+ * @returns {{ port: number, version: number, speakerId: number,
86
+ * talkId: number, seq: number }|null}
87
+ */
88
+ export function readVoiceHeader(bytes) {
89
+ if (
90
+ !bytes ||
91
+ bytes.length <= VOICE_HEADER_BYTES ||
92
+ bytes.length > VOICE_MAX_PACKET_BYTES ||
93
+ bytes[1] !== VOICE_PACKET_VERSION
94
+ ) {
95
+ return null;
96
+ }
97
+
98
+ return {
99
+ port: bytes[0],
100
+ version: bytes[1],
101
+ speakerId: bytes[2],
102
+ talkId: bytes[3],
103
+ seq: new DataView(bytes.buffer, bytes.byteOffset).getUint16(4),
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Готовит пакет клиента к раздаче: байт 0 ← server.VOICE_AUDIO, байт 2 ←
109
+ * speakerId. Правит буфер на месте (ретранслятор владеет им).
110
+ * @param {Uint8Array} bytes
111
+ * @param {number} speakerId - 0..VOICE_MAX_SPEAKER_ID.
112
+ * @returns {Uint8Array}
113
+ */
114
+ export function stampForListeners(bytes, speakerId) {
115
+ if (!isVoiceSpeakerId(speakerId)) {
116
+ throw new RangeError('voice packet: speakerId out of range');
117
+ }
118
+
119
+ bytes[0] = wsports.server.VOICE_AUDIO;
120
+ bytes[2] = speakerId;
121
+
122
+ return bytes;
123
+ }
124
+
125
+ /**
126
+ * Полезная нагрузка (Opus) без копии.
127
+ * @param {Uint8Array} bytes
128
+ * @returns {Uint8Array}
129
+ */
130
+ export function voicePayload(bytes) {
131
+ return bytes.subarray(VOICE_HEADER_BYTES);
132
+ }
133
+
134
+ /**
135
+ * Знаковая разность номеров u16 с переполнением: seqDiff(1, 65535) === 2.
136
+ * @param {number} a
137
+ * @param {number} b
138
+ * @returns {number}
139
+ */
140
+ export function seqDiff(a, b) {
141
+ return ((a - b + 32768) & 0xffff) - 32768;
142
+ }