@ykurilov/agent-chat-voice 0.0.0-stage → 0.55.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/CHANGELOG.md +610 -0
- package/LICENSE +21 -0
- package/README.md +298 -2
- package/dist/chunk-CAERXPTX.js +103 -0
- package/dist/chunk-CAERXPTX.js.map +1 -0
- package/dist/hono.js +45 -0
- package/dist/hono.js.map +1 -0
- package/dist/index.js +323 -0
- package/dist/index.js.map +1 -0
- package/dist/node.js +86 -0
- package/dist/node.js.map +1 -0
- package/dist/server.js +869 -0
- package/dist/server.js.map +1 -0
- package/dist/types/hono.d.ts +10 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/node.d.ts +30 -0
- package/dist/types/relay-close.d.ts +21 -0
- package/dist/types/request-session.d.ts +13 -0
- package/dist/types/server/access.d.ts +52 -0
- package/dist/types/server/language-hints.d.ts +5 -0
- package/dist/types/server/platform-websocket.d.ts +2 -0
- package/dist/types/server/polish.d.ts +73 -0
- package/dist/types/server/request-origin.d.ts +34 -0
- package/dist/types/server/soniox-session.d.ts +88 -0
- package/dist/types/server/temporary-key.d.ts +20 -0
- package/dist/types/server/transcribe-audio-file.d.ts +11 -0
- package/dist/types/server/voice-handler.d.ts +52 -0
- package/dist/types/server/voice-relay.d.ts +101 -0
- package/dist/types/server/voice-spending.d.ts +44 -0
- package/dist/types/server.d.ts +18 -0
- package/dist/types/soniox-messages.d.ts +74 -0
- package/dist/types/voice-errors.d.ts +20 -0
- package/dist/types/voice-input.d.ts +61 -0
- package/package.json +71 -3
package/README.md
CHANGED
|
@@ -1,3 +1,299 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ykurilov/agent-chat-voice
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Голосовой ввод для чата с агентом через Soniox: человек нажимает микрофон в поле ввода, слова появляются в поле
|
|
4
|
+
по ходу речи, он правит и отправляет сам. Отдельное расширение: приложению без голоса пакет не нужен, а чат без него
|
|
5
|
+
кнопки микрофона не рисует.
|
|
6
|
+
|
|
7
|
+
Пакет про **диктовку в поле**. Голосовой разговор с агентом (говорит человек, отвечает голос) сюда не входит:
|
|
8
|
+
для него будет отдельный пакет, а серверную часть связи с Soniox он возьмёт отсюда (см. «Сессия Soniox»).
|
|
9
|
+
|
|
10
|
+
Два режима, оба держат главный ключ на сервере:
|
|
11
|
+
|
|
12
|
+
- **Через сервер** (`mode: "relay"`) — **основной и рекомендуемый путь**. Браузер шлёт звук на ваш сервер по WebSocket,
|
|
13
|
+
сервер пересылает его в Soniox и возвращает текст. Ключ Soniox не покидает сервер даже в виде временного, работает
|
|
14
|
+
из России (сервер ходит через прокси), и только в этом режиме есть чистка текста моделью. Цена: серверу нужен WebSocket.
|
|
15
|
+
- **Напрямую** (по умолчанию, **не рекомендуется**): сервер выдаёт браузеру временный ключ Soniox, браузер соединяется
|
|
16
|
+
с Soniox сам. Оставлен для приложений, которые уже так работают. Временный ключ всё же уходит в браузер, чистки
|
|
17
|
+
текста нет, а из России браузер до Soniox не достаёт.
|
|
18
|
+
|
|
19
|
+
## Через сервер (рекомендуется)
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { createVoiceRelay } from "@ykurilov/agent-chat-voice/server";
|
|
23
|
+
import { mountVoiceRelay } from "@ykurilov/agent-chat-voice/hono";
|
|
24
|
+
import { createNodeWebSocket } from "@hono/node-ws";
|
|
25
|
+
import { serve } from "@hono/node-server";
|
|
26
|
+
|
|
27
|
+
const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app });
|
|
28
|
+
const relay = createVoiceRelay({
|
|
29
|
+
resolveUser: async (request) => currentUser(request), // допуск проверяется до подъёма WebSocket; нет пользователя → 401
|
|
30
|
+
// ключ: SONIOX_API_KEY из окружения (или apiKey: "…")
|
|
31
|
+
websocketUrl: process.env.SONIOX_WEBSOCKET_URL, // из России — адрес маршрута прокси до stt-rt.soniox.com
|
|
32
|
+
upstreamHeaders: { "X-Proxy-Token": process.env.PROXY_TOKEN! }, // сервер может добавить заголовок
|
|
33
|
+
polish: { provider: "together" }, // чистка текста моделью, по желанию (ниже)
|
|
34
|
+
allowedOrigins: ["https://school.getcourse.ru"], // по желанию: чужие страницы, которым можно; свой сайт проходит всегда
|
|
35
|
+
});
|
|
36
|
+
mountVoiceRelay(app, "/api/voice/stream", relay, upgradeWebSocket);
|
|
37
|
+
injectWebSocket(serve({ fetch: app.fetch, port: 8787 }));
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
const voice = createVoiceInput({ api: "/api/voice", mode: "relay" }); // адрес — {api}/stream на том же сайте
|
|
42
|
+
// другой адрес: relayUrl: "wss://voice.example.com/stream" или "/voice/stream" (от адреса страницы)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Настройки: `model` (по умолчанию `stt-rt-v5`, последняя real-time модель Soniox), `languageHints` (по умолчанию `["ru"]`),
|
|
46
|
+
`allowedLanguageHints`, `context` (тема и термины для точности), `maxSessionSeconds` (по умолчанию 300), `rateLimit`,
|
|
47
|
+
`polishRateLimit`, `maxConnectionsPerUser`, `logError`, `websocketUrl`, `upstreamHeaders`, `polish`, `spending`.
|
|
48
|
+
|
|
49
|
+
Определение конца фразы Soniox (`enable_endpoint_detection`) включено: слова закрепляются быстрее.
|
|
50
|
+
|
|
51
|
+
**Стоп и финал.** По «стоп» клиент шлёт серверу `finalize`, сервер передаёт его Soniox, и тот закрепляет всё сказанное
|
|
52
|
+
и присылает метку `<fin>`. Только после неё текст считается готовым. Если Soniox не ответил за `stopTimeoutMs`
|
|
53
|
+
(3 секунды по умолчанию), остаётся то, что уже пришло, без ошибки.
|
|
54
|
+
|
|
55
|
+
**Частота.** Один человек может начать не больше 10 диктовок в минуту (`rateLimit: { maxSessions, perSeconds }`,
|
|
56
|
+
`rateLimit: false` — выключить). Сверх этого — 429 и «слишком часто» у человека. Отдельно: не больше 2 соединений
|
|
57
|
+
одновременно на человека (`maxConnectionsPerUser`, `false` — без предела); лишнее — 429 или код закрытия 4429. Счётчики живут
|
|
58
|
+
в памяти одного процесса; общие лимиты (на всё приложение, на несколько процессов, на расходы Soniox) — задача приложения.
|
|
59
|
+
|
|
60
|
+
**Почему отказали.** Браузер не видит HTTP-ответ на подъём WebSocket: для него любой такой отказ — «нет связи».
|
|
61
|
+
Поэтому сервер поднимает соединение и сразу закрывает его кодом причины, а браузер показывает свою подпись:
|
|
62
|
+
|
|
63
|
+
| Причина | Код закрытия | Ошибка в браузере | Подпись по умолчанию |
|
|
64
|
+
|---|---|---|---|
|
|
65
|
+
| не вошёл (`resolveUser` → `null`) | 4401 | `forbidden` | «Нет доступа к диктовке» |
|
|
66
|
+
| приложение отказало (`{ refused: { code } }`) | по `code`, ниже | | |
|
|
67
|
+
| — `code: "limit"` или `"spending_limit_reached"` | 4402 | `limit` | «Лимит расходов исчерпан, диктовка недоступна» |
|
|
68
|
+
| — `code: "too_long"` | 4413 | `too_long` | «Запись слишком длинная» |
|
|
69
|
+
| — `code: "rate_limited"` | 4429 | `rate_limited` | «Слишком много диктовок подряд…» |
|
|
70
|
+
| — `code: "service_unavailable"` | 4502 | `service_unavailable` | «Сервис распознавания речи недоступен» |
|
|
71
|
+
| — любой другой `code` | 4403 | `forbidden` | «Нет доступа к диктовке» |
|
|
72
|
+
| лимит расходов (`spending`) | 4402 | `limit` | как у `limit` |
|
|
73
|
+
| слишком часто | 4429 | `rate_limited` | |
|
|
74
|
+
| нет ключа Soniox, Soniox недоступен | 4502 | `service_unavailable` | |
|
|
75
|
+
| кусок звука больше предела сервера (`maxFrameBytes`) | 1009 | `too_long` | |
|
|
76
|
+
| нет приветствия 10 с после подъёма (`helloTimeoutMs`) | 4400 | `other` | |
|
|
77
|
+
| чужая страница (Origin) | HTTP 403, соединения нет | `network` | |
|
|
78
|
+
|
|
79
|
+
Коды — `RELAY_CLOSE` из `@ykurilov/agent-chat-voice/server` (и из корня пакета для браузера). В режиме «напрямую» те же
|
|
80
|
+
причины приходят статусом ответа: 401/403 → `forbidden`, 402 → `limit`, 429 → `rate_limited`.
|
|
81
|
+
|
|
82
|
+
### Next.js: свой сервер (custom server)
|
|
83
|
+
|
|
84
|
+
Route handlers Next.js не держат WebSocket. Голос встаёт на тот же HTTP-сервер, что и Next.js, обработчиком `upgrade`.
|
|
85
|
+
Проверки те же, что у Hono: своя страница (Origin), вход, частота, лимит расходов, причина отказа кодом закрытия.
|
|
86
|
+
Нужен пакет `ws` (`pnpm add ws`).
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
// server.ts — запуск: node server.ts (Node 22.18+) или node --import tsx server.ts (вместо next start)
|
|
90
|
+
import { createServer } from "node:http";
|
|
91
|
+
import next from "next";
|
|
92
|
+
import { createVoiceUpgrade, sharedVoiceRelay } from "@ykurilov/agent-chat-voice/node";
|
|
93
|
+
|
|
94
|
+
// Свой обработчик upgrade Next.js вешает на `httpServer` (без него — на сервер первого запроса) и закрывает
|
|
95
|
+
// соединения, которые совпали с его маршрутом. Отдельный сервер, который не слушает порт, держит этот обработчик
|
|
96
|
+
// в стороне: голос Next.js не видит, а свои соединения (горячая перезагрузка) получает через `emit` ниже.
|
|
97
|
+
const nextUpgrades = createServer();
|
|
98
|
+
const app = next({ dev: process.env.NODE_ENV !== "production", httpServer: nextUpgrades });
|
|
99
|
+
const handle = app.getRequestHandler();
|
|
100
|
+
await app.prepare();
|
|
101
|
+
|
|
102
|
+
const voiceUpgrade = createVoiceUpgrade({ path: "/api/voice/stream", relay: sharedVoiceRelay });
|
|
103
|
+
const server = createServer((request, response) => handle(request, response));
|
|
104
|
+
server.on("upgrade", (request, socket, head) => {
|
|
105
|
+
if (!voiceUpgrade(request, socket, head)) nextUpgrades.emit("upgrade", request, socket, head);
|
|
106
|
+
});
|
|
107
|
+
server.listen(Number(process.env.PORT ?? 3000));
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
// instrumentation.ts (или любой модуль, который Next.js грузит при старте): ретранслятор живёт рядом с кодом приложения
|
|
112
|
+
import { shareVoiceRelay } from "@ykurilov/agent-chat-voice/node";
|
|
113
|
+
import { createVoiceRelay } from "@ykurilov/agent-chat-voice/server";
|
|
114
|
+
|
|
115
|
+
export async function register() {
|
|
116
|
+
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
|
117
|
+
shareVoiceRelay(createVoiceRelay({ resolveUser: currentUser, spending: { service: chatService, recognitionUsdPerHour: 0.12 } }));
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
- `createVoiceUpgrade({ path, relay, maxFrameBytes, authorizeTimeoutMs })` возвращает обработчик `upgrade`: `true` — соединение голоса,
|
|
122
|
+
`false` — адрес чужой, отдайте его Next.js. `relay` — ретранслятор или функция, которая его отдаёт.
|
|
123
|
+
- **Не вешайте Next.js на тот же сервер**, что и голос (`httpServer: nextUpgrades` обязателен). Иначе после первой
|
|
124
|
+
страницы Next.js сам подпишется на `upgrade` настоящего сервера и при маршруте, который ловит любой адрес
|
|
125
|
+
(`app/[...slug]`), закроет соединение голоса: диктовка оборвётся с «нет связи». `app.getUpgradeHandler()` вместо
|
|
126
|
+
`emit` не подходит: горячую перезагрузку он не обслуживает. Проверено вживую на Next.js 16.3 (`next build` и `next dev`).
|
|
127
|
+
- `shareVoiceRelay` / `sharedVoiceRelay` — мостик внутри процесса. Next.js собирает свой код отдельно от `server.ts`,
|
|
128
|
+
и общий модуль у них — разные копии; мостик держит ретранслятор на `globalThis`. Пока его нет — ответ 503.
|
|
129
|
+
- Ретранслятор можно создать и прямо в `server.ts` и передать `relay: relay`, если всё нужное (`resolveUser`, служба
|
|
130
|
+
чата) доступно оттуда.
|
|
131
|
+
- `maxFrameBytes` — предел одного куска звука, по умолчанию 1 МиБ; больше — закрытие 1009 и «Запись слишком длинная».
|
|
132
|
+
Тот же предел есть у ретранслятора (`createVoiceRelay({ maxFrameBytes })`, он действует и для Hono); здесь кусок
|
|
133
|
+
отрезается ещё на входе, не попадая в память целиком.
|
|
134
|
+
- `authorizeTimeoutMs` — сколько ждать проверки входа (`resolveUser`, лимит), по умолчанию 10 с; дольше — HTTP 503.
|
|
135
|
+
- Сеть не роняет процесс: путь, который не разбирается (`GET //`), получает HTTP 400, обрыв во время проверки входа
|
|
136
|
+
просто закрывает сокет.
|
|
137
|
+
- Тот же обработчик подходит для Express и голого `node:http`.
|
|
138
|
+
|
|
139
|
+
Другой путь — маленький Node-сервер на Hono рядом (код выше, отдельный порт или поддомен, `relayUrl` указывает на него;
|
|
140
|
+
вход `resolveUser` читает тот же cookie, поэтому нужен общий домен).
|
|
141
|
+
|
|
142
|
+
### Учёт расходов и лимиты
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
const relay = createVoiceRelay({
|
|
146
|
+
resolveUser,
|
|
147
|
+
spending: { service: chatService, recognitionUsdPerHour: 0.12 }, // chatService = createChatService(...)
|
|
148
|
+
polish: { askModel: chatService.askModel, model: { providerId: "together", modelId: "deepseek-ai/DeepSeek-V4.1-Flash" } },
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
- До диктовки сервер проверяет лимит расходов (`service.assertWithinLimit`): исчерпан — соединение закрывается кодом 4402,
|
|
153
|
+
человек видит «Лимит расходов исчерпан, диктовка недоступна». Soniox не зовётся.
|
|
154
|
+
- Секунды звука пишутся в журнал расхода видом `voice_input` один раз в конце диктовки: от первого куска звука до конца
|
|
155
|
+
соединения, начатая секунда — целая, не больше предела записи. Цена — `секунды × recognitionUsdPerHour / 3600`,
|
|
156
|
+
`inputTokens` — секунды. Так голос попадает в общие лимиты (`@ykurilov/agent-chat-limits`) и отчёты.
|
|
157
|
+
- Чат расхода — `?chat=` адреса соединения (его ставит `createVoiceInput`, `AgentChat` передаёт текущий чат), если это
|
|
158
|
+
чат вошедшего: сервер проверяет его `service.assertOwnChat`. Нет его, чужой или странный — метка `"voice"`, так чужой
|
|
159
|
+
адрес не тратит лимит чужого чата. Свой выбор — `spending.chatId(request, user)`. Расход всегда на вошедшем человеке.
|
|
160
|
+
- Чистка текста пишется видом `voice_polish` (ниже).
|
|
161
|
+
- Только режим «через сервер»: «напрямую» звук идёт мимо сервера, и секунд он не видит.
|
|
162
|
+
|
|
163
|
+
### Язык записи
|
|
164
|
+
|
|
165
|
+
Браузер может назвать язык каждой записи: `AgentChat` передаёт язык словаря подписей (`labels.locale`), своё приложение —
|
|
166
|
+
`createVoiceInput({ languageHints: () => ["ru", "en"] })` или `voice.start(listener, { language: "en" })`.
|
|
167
|
+
Сервер не верит браузеру на слово: берёт только коды языков из двух-трёх букв (`en-US` → `en`), без повторов, не больше
|
|
168
|
+
трёх. `allowedLanguageHints: ["ru", "en"]` сужает выбор. Ничего годного — подсказка сервера (`languageHints`). Старый
|
|
169
|
+
сервер поле `language_hints` в приветствии просто не читает.
|
|
170
|
+
|
|
171
|
+
**Из России** нужен маршрут прокси и для WebSocket: обычного маршрута на `api.soniox.com` мало, нужен ещё
|
|
172
|
+
`stt-rt.soniox.com` с проверкой токена и заголовками `Upgrade`/`Connection`.
|
|
173
|
+
|
|
174
|
+
### Чистка текста моделью
|
|
175
|
+
|
|
176
|
+
После «стоп» сервер может прогнать надиктованное через модель: убрать «э-э», повторы и оговорки, расставить знаки и заглавные.
|
|
177
|
+
Модель не пересказывает, не отвечает на вопрос из текста, не меняет смысл и язык.
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
polish: { provider: "together" } // ключ TOGETHER_API_KEY из окружения сервера
|
|
181
|
+
polish: { provider: "together", model: "…", apiKey: "…", timeoutMs: 5000 }
|
|
182
|
+
polish: { provider: "together", baseUrl: "https://fr-api-route.yeycourses.com/together/v1", headers: { "X-Proxy-Token": process.env.PROXY_TOKEN! } } // через прокси
|
|
183
|
+
polish: { clean: async (text) => myModel(text) } // своя чистка: вернуть исправленный текст
|
|
184
|
+
polish: { askModel: chatService.askModel, model: { providerId, modelId } } // модель службы чата: учёт и лимиты сами
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
- По умолчанию модель `deepseek-ai/DeepSeek-V4.1-Flash` у Together (без рассуждений, температура 0). Запрос устроен под кэш модели:
|
|
188
|
+
постоянная инструкция первой, надиктованный текст последним.
|
|
189
|
+
- **Куда уходит текст.** Надиктованный текст отправляется в модель чистки (Together или ваша функция `clean`): это ещё один
|
|
190
|
+
сторонний сервис рядом с Soniox. Упомяните его в политике приватности. Ключ Together остаётся на сервере и в браузер не попадает.
|
|
191
|
+
- Текст на чистку берёт сам сервер из ответов Soniox, клиент его не присылает: это не открытая дверь к модели.
|
|
192
|
+
- В поле заменяется только надиктованный кусок. Набранное до и после остаётся; дописанное руками во время чистки сохраняется;
|
|
193
|
+
если человек поправил само надиктованное, чистка не применяется и его правка остаётся. Пока идёт чистка, у кнопки микрофона
|
|
194
|
+
тихий признак (`VoiceInputView.polishing`, подпись `voicePolishing`).
|
|
195
|
+
- Сбой, ошибка HTTP, пустой, «раздутый» или слишком короткий ответ, ответ другими словами (меньше 70% слов из исходника: так
|
|
196
|
+
выглядит «ответил на вопрос» или отказ), тишина дольше 5 секунд — остаётся текст Soniox, без ошибки на экране.
|
|
197
|
+
Клиент ждёт чистку не дольше `polishTimeoutMs` (7 секунд по умолчанию).
|
|
198
|
+
- Нет ключа Together — чистка выключена, диктовка работает, причина в `logError`.
|
|
199
|
+
- **Расходы.** Чистка пишется в журнал расхода видом `voice_polish` и попадает в лимиты:
|
|
200
|
+
- `askModel` службы чата — всегда: служба сама проверяет лимит и пишет расход (вариант по умолчанию для приложений
|
|
201
|
+
с `createChatService`);
|
|
202
|
+
- Together — вместе с `spending` и ценой модели `pricing: { inputPerMillion, outputPerMillion }` (доллары за миллион
|
|
203
|
+
токенов); без цены при `spending` ретранслятор не создастся: расход прошёл бы мимо лимитов;
|
|
204
|
+
- своя чистка — вместе с `spending`, если `clean` вернёт `{ text, usage: { inputTokens, outputTokens, costUsd, modelId } }`.
|
|
205
|
+
Вернула просто текст (без `usage`) — её расход не учитывается: в журнал он не попадает, лимит его не видит. Лимит перед
|
|
206
|
+
чисткой проверяется и в этом случае.
|
|
207
|
+
Лимит исчерпан — чистки нет, текст остаётся как сказал Soniox. Расход пишется, как только модель ответила, даже если
|
|
208
|
+
ответ отброшен проверками. Отдельно у чистки свой лимит частоты: 6 чисток в минуту на человека
|
|
209
|
+
(`polishRateLimit: { maxSessions, perSeconds }`, `polishRateLimit: false` — выключить). Он работает и при `rateLimit: false`.
|
|
210
|
+
|
|
211
|
+
### Как устроено
|
|
212
|
+
|
|
213
|
+
`createVoiceRelay` даёт `authorize(request)` → `{ user, chatId }` или `{ refusal, closeCode? }`, и `accept(socket, user, chatId)`.
|
|
214
|
+
Клиент шлёт `{"audio_format":"auto","language_hints":["ru"]}` (`language_hints` необязательно), сервер
|
|
215
|
+
соединяется с Soniox и отвечает `{"ready":true,"max_session_seconds":300,"polish":true}` (`polish` только если чистка включена),
|
|
216
|
+
после этого клиент шлёт куски звука, команду `{"type":"finalize"}` по «стоп» и, если нужно, пустое сообщение (конец звука).
|
|
217
|
+
Ответы Soniox сервер пересылает как есть, после `<fin>` присылает `{"polished":{"text":"…"}}` (`null` — оставить как есть).
|
|
218
|
+
Приветствие нужно прислать за 10 с (`helloTimeoutMs`), сообщение — не больше 1 МиБ (`maxFrameBytes`).
|
|
219
|
+
Коды закрытия: 4400 нарушен протокол или нет приветствия, 4401 не вошёл, 4402 лимит расходов, 4403 нет доступа, 4408 вышло время, 4413 слишком
|
|
220
|
+
длинно, 4429 слишком часто, 4502 Soniox недоступен, 1009 кусок больше предела.
|
|
221
|
+
Полностью — `specs/009-voice-input/contracts/voice.md` и `specs/013-voice-realtime/contracts/relay.md`.
|
|
222
|
+
|
|
223
|
+
### Сессия Soniox (для повторного использования)
|
|
224
|
+
|
|
225
|
+
Связь сервера с Soniox вынесена в отдельный модуль без поля ввода и без WebSocket браузера:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
import { createSonioxSession } from "@ykurilov/agent-chat-voice/server";
|
|
229
|
+
|
|
230
|
+
const session = createSonioxSession(
|
|
231
|
+
{ apiKey, model: "stt-rt-v5", languageHints: ["ru"], websocketUrl, logError },
|
|
232
|
+
{ onReady() {}, onMessage(raw, event) {}, onClosed(reason) {} },
|
|
233
|
+
);
|
|
234
|
+
session.begin({ audio_format: "auto" }); // потом sendAudio(chunk), finalize(), endAudio(), text(), close()
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Она сама открывает соединение, шлёт первое сообщение, копит закреплённый и предварительный текст, ловит `<fin>`,
|
|
238
|
+
следит за пределом записи и не выдаёт ключ в ответах. На ней стоит `createVoiceRelay`; на ней же сможет стоять голосовой режим.
|
|
239
|
+
|
|
240
|
+
## Напрямую (не рекомендуется)
|
|
241
|
+
|
|
242
|
+
Сервер выдаёт браузеру временный ключ Soniox (живёт минуту, один раз, диктовка до пяти минут), браузер соединяется с Soniox сам.
|
|
243
|
+
Чистки текста в этом режиме нет.
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
import { createVoiceHandler } from "@ykurilov/agent-chat-voice/server";
|
|
247
|
+
import { mountChat } from "@ykurilov/agent-chat-server/hono"; // или createNextRouteHandlers из …/next
|
|
248
|
+
|
|
249
|
+
const voice = createVoiceHandler({
|
|
250
|
+
basePath: "/api/voice",
|
|
251
|
+
resolveUser: async (request) => currentUser(request), // тот же вход, что у чата; null → 401
|
|
252
|
+
});
|
|
253
|
+
mountChat(app, voice);
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Один адрес: `POST /api/voice/session` → `{ apiKey, websocketUrl, model, languageHints, context? }`. Тело запроса
|
|
257
|
+
`{ languageHints }` (необязательно) — язык этой записи, сервер проверяет его так же, как в режиме «через сервер».
|
|
258
|
+
Нет ключа на сервере или Soniox не ответил — 503/502: чат работает, человек видит «Сервис распознавания речи недоступен».
|
|
259
|
+
Настройки: `model`, `languageHints`, `allowedLanguageHints`, `context`, `tokenLifetimeSeconds`, `maxSessionSeconds`,
|
|
260
|
+
`rateLimit`, `logError`. Учёта расходов в этом режиме нет.
|
|
261
|
+
Ответ с ключом идёт с `Cache-Control: no-store`. Российский сервер: `apiBaseUrl`, `apiHeaders`, `websocketUrl`, но браузер
|
|
262
|
+
подключается к `websocketUrl` сам и заголовок добавить не может, поэтому прокси для WebSocket должен пускать без него.
|
|
263
|
+
|
|
264
|
+
## Браузер
|
|
265
|
+
|
|
266
|
+
```tsx
|
|
267
|
+
import { createVoiceInput } from "@ykurilov/agent-chat-voice";
|
|
268
|
+
|
|
269
|
+
const voice = createVoiceInput({ api: "/api/voice", mode: "relay" }); // один на приложение
|
|
270
|
+
<AgentChat api="/api/chat" chatId={chatId} skin={tandemSkin} voice={voice} />;
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
- Нажатие: запрос разрешения на микрофон → соединение с сервером → запись потоком. Повторное нажатие — стоп: остаток речи
|
|
274
|
+
закрепляется (`finalize`), микрофон отпускается. Нажатие во время подключения отменяет запуск.
|
|
275
|
+
- Слова дописываются к тому, что уже набрано. Ручная правка во время диктовки остаётся, сказанное раньше не дублируется.
|
|
276
|
+
Голос сам ничего не отправляет.
|
|
277
|
+
- Нет доступа к микрофону, нет микрофона, нет связи, сервис недоступен, лимит, нет доступа, слишком длинно — понятная
|
|
278
|
+
надпись из словаря чата (`voiceErrors`), чат работает дальше.
|
|
279
|
+
- Подписи — `voiceStart`, `voiceStop`, `voiceConnecting`, `voicePolishing`, `voiceErrors` в словаре `russianLabels`.
|
|
280
|
+
Подписи `limit`, `forbidden`, `too_long` в своём словаре можно не задавать: возьмутся подписи по умолчанию на языке
|
|
281
|
+
словаря. `time_limit` — строка или функция от предела записи в секундах.
|
|
282
|
+
- `voice.start(listener, { chatId, language })`: чат расхода (режим «через сервер») и язык записи. `AgentChat` передаёт их
|
|
283
|
+
сам. `onError(kind, details)`: у `time_limit` в `details.maxSessionSeconds` предел, названный сервером.
|
|
284
|
+
- Формат записи выбирается явно: webm/opus (Chrome, Firefox), затем ogg/opus, затем mp4 (Safari). Soniox сам
|
|
285
|
+
определяет контейнер. Нужен `https` или `localhost`.
|
|
286
|
+
- Предел диктовки — `maxSessionSeconds` сервера (5 минут по умолчанию). За две секунды до него запись останавливается,
|
|
287
|
+
слова сохраняются, человек видит «Запись остановлена: предел 5 минут» — с числом из настройки сервера. Отправка сообщения во время диктовки останавливает диктовку.
|
|
288
|
+
|
|
289
|
+
Договор скина версии 5: `Composer` получает необязательное свойство `voice` (`VoiceInputView`); нет его — кнопку не рисовать.
|
|
290
|
+
Необязательное `voice.polishing` — признак «идёт чистка»; скин, который его не знает, работает как раньше.
|
|
291
|
+
Подробнее — `specs/009-voice-input/contracts/voice.md`.
|
|
292
|
+
|
|
293
|
+
## Проверка вживую
|
|
294
|
+
|
|
295
|
+
`pnpm secrets:load` кладёт ключ из `~/.config/soniox/.env` в `.env.local`; `pnpm test:live soniox` озвучивает
|
|
296
|
+
русскую фразу (`say -v Milena`), отправляет в Soniox тем же путём, что браузер, и ждёт текст. Из России добавьте
|
|
297
|
+
`SONIOX_API_BASE_URL` и `PROXY_TOKEN`. `soniox-formats` проверяет куски webm/opus, ogg/opus и mp4/AAC,
|
|
298
|
+
`soniox-relay` — весь путь «клиент → сервер Hono → Soniox» (для России ещё `SONIOX_WEBSOCKET_URL`), с замером, когда пришли предварительные слова и финал
|
|
299
|
+
после `finalize`, и одним вызовом чистки, если есть `TOGETHER_API_KEY`.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// src/relay-close.ts
|
|
2
|
+
var RELAY_CLOSE = {
|
|
3
|
+
normal: 1e3,
|
|
4
|
+
/** Кусок звука больше предела сервера (стандартный код WebSocket). */
|
|
5
|
+
messageTooBig: 1009,
|
|
6
|
+
protocol: 4400,
|
|
7
|
+
/** Не вошёл. */
|
|
8
|
+
unauthorized: 4401,
|
|
9
|
+
/** Лимит расходов исчерпан. */
|
|
10
|
+
limit: 4402,
|
|
11
|
+
/** Нет доступа: приложение отказало. */
|
|
12
|
+
forbidden: 4403,
|
|
13
|
+
timeLimit: 4408,
|
|
14
|
+
/** Слишком длинно: приложение отказало по длине. */
|
|
15
|
+
tooLong: 4413,
|
|
16
|
+
rateLimited: 4429,
|
|
17
|
+
upstream: 4502
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// src/soniox-messages.ts
|
|
21
|
+
function buildStartMessage({ apiKey, model, languageHints, context, endpointLatencyLevel, maxEndpointDelayMs }) {
|
|
22
|
+
return {
|
|
23
|
+
api_key: apiKey,
|
|
24
|
+
model,
|
|
25
|
+
audio_format: "auto",
|
|
26
|
+
language_hints: languageHints,
|
|
27
|
+
...context && { context },
|
|
28
|
+
enable_endpoint_detection: true,
|
|
29
|
+
...endpointLatencyLevel !== void 0 && { endpoint_latency_adjustment_level: endpointLatencyLevel },
|
|
30
|
+
...maxEndpointDelayMs !== void 0 && { max_endpoint_delay_ms: maxEndpointDelayMs }
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function buildFinalizeMessage() {
|
|
34
|
+
return { type: "finalize" };
|
|
35
|
+
}
|
|
36
|
+
var FINALIZED_MARK = "<fin>";
|
|
37
|
+
var PHRASE_END_MARK = "<end>";
|
|
38
|
+
var serviceTokens = /* @__PURE__ */ new Set([FINALIZED_MARK, PHRASE_END_MARK]);
|
|
39
|
+
function createTranscript({ phraseEnds = false } = {}) {
|
|
40
|
+
let finalText = "";
|
|
41
|
+
let phraseText = "";
|
|
42
|
+
let interimText = "";
|
|
43
|
+
const snapshot = () => ({ finalText, interimText });
|
|
44
|
+
return {
|
|
45
|
+
accept(raw) {
|
|
46
|
+
let message;
|
|
47
|
+
try {
|
|
48
|
+
message = JSON.parse(raw);
|
|
49
|
+
} catch {
|
|
50
|
+
return { type: "ignored" };
|
|
51
|
+
}
|
|
52
|
+
if (typeof message !== "object" || message === null) return { type: "ignored" };
|
|
53
|
+
if (message.error_code !== void 0 || message.error_message !== void 0) {
|
|
54
|
+
return {
|
|
55
|
+
type: "error",
|
|
56
|
+
code: typeof message.error_code === "number" ? message.error_code : 0,
|
|
57
|
+
message: typeof message.error_message === "string" ? message.error_message : ""
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const hasTokens = Array.isArray(message.tokens);
|
|
61
|
+
let finalized = false;
|
|
62
|
+
const endedPhrases = [];
|
|
63
|
+
if (hasTokens) {
|
|
64
|
+
let interim = "";
|
|
65
|
+
for (const token of message.tokens) {
|
|
66
|
+
if (typeof token.text !== "string") continue;
|
|
67
|
+
if (token.text === FINALIZED_MARK) finalized = true;
|
|
68
|
+
if (token.text === PHRASE_END_MARK && phraseEnds) {
|
|
69
|
+
endedPhrases.push(phraseText.trim());
|
|
70
|
+
phraseText = "";
|
|
71
|
+
}
|
|
72
|
+
if (serviceTokens.has(token.text)) continue;
|
|
73
|
+
if (token.is_final === true) {
|
|
74
|
+
finalText += token.text;
|
|
75
|
+
phraseText += token.text;
|
|
76
|
+
} else interim += token.text;
|
|
77
|
+
}
|
|
78
|
+
interimText = interim;
|
|
79
|
+
}
|
|
80
|
+
const phrases = endedPhrases.length > 0 ? { endedPhrases } : {};
|
|
81
|
+
if (message.finished === true) {
|
|
82
|
+
return hasTokens && message.tokens.length > 0 ? { type: "finished", text: snapshot(), ...phrases } : { type: "finished" };
|
|
83
|
+
}
|
|
84
|
+
if (!hasTokens) return { type: "ignored" };
|
|
85
|
+
return { type: "text", text: snapshot(), ...finalized && { finalized: true }, ...phrases };
|
|
86
|
+
},
|
|
87
|
+
/** Диктовка закончилась: что Soniox не успел закрепить, остаётся как есть. */
|
|
88
|
+
settle() {
|
|
89
|
+
finalText += interimText;
|
|
90
|
+
interimText = "";
|
|
91
|
+
return snapshot();
|
|
92
|
+
},
|
|
93
|
+
current: snapshot
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export {
|
|
98
|
+
buildStartMessage,
|
|
99
|
+
buildFinalizeMessage,
|
|
100
|
+
createTranscript,
|
|
101
|
+
RELAY_CLOSE
|
|
102
|
+
};
|
|
103
|
+
//# sourceMappingURL=chunk-CAERXPTX.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/relay-close.ts","../src/soniox-messages.ts"],"sourcesContent":["/**\n * Коды закрытия соединения браузера с сервером приложения (режим `relay`): по ним браузер понимает, что случилось,\n * и показывает свою подпись. Общие для сервера и браузера.\n */\nexport const RELAY_CLOSE = {\n normal: 1000,\n /** Кусок звука больше предела сервера (стандартный код WebSocket). */\n messageTooBig: 1009,\n protocol: 4400,\n /** Не вошёл. */\n unauthorized: 4401,\n /** Лимит расходов исчерпан. */\n limit: 4402,\n /** Нет доступа: приложение отказало. */\n forbidden: 4403,\n timeLimit: 4408,\n /** Слишком длинно: приложение отказало по длине. */\n tooLong: 4413,\n rateLimited: 4429,\n upstream: 4502,\n} as const;\n","/** Всё, что человек уже сказал: закреплённое распознавание и то, что Soniox ещё может поправить. */\nexport interface DictatedText {\n finalText: string;\n interimText: string;\n}\n\n/** Подсказка Soniox: тема и термины, чтобы точнее распознавать узкие слова. */\nexport interface SonioxContext {\n general?: Array<{ key: string; value: string }>;\n text?: string;\n terms?: string[];\n}\n\nexport interface StartMessageOptions {\n apiKey: string;\n model: string;\n languageHints: string[];\n context?: SonioxContext | undefined;\n /** Как быстро Soniox объявляет конец фразы: чем меньше число, тем раньше (и тем чаще ошибается на паузах). Не задан — по умолчанию Soniox. */\n endpointLatencyLevel?: number | undefined;\n /** Самая долгая пауза до конца фразы, мс. Не задан — по умолчанию Soniox. */\n maxEndpointDelayMs?: number | undefined;\n}\n\n/**\n * Первое сообщение в WebSocket Soniox: ключ и настройки распознавания. Формат звука Soniox определяет сам.\n * Границы фраз (`enable_endpoint_detection`): на паузе Soniox закрепляет сказанное, не дожидаясь конца диктовки.\n */\nexport function buildStartMessage({ apiKey, model, languageHints, context, endpointLatencyLevel, maxEndpointDelayMs }: StartMessageOptions) {\n return {\n api_key: apiKey,\n model,\n audio_format: \"auto\",\n language_hints: languageHints,\n ...(context && { context }),\n enable_endpoint_detection: true,\n ...(endpointLatencyLevel !== undefined && { endpoint_latency_adjustment_level: endpointLatencyLevel }),\n ...(maxEndpointDelayMs !== undefined && { max_endpoint_delay_ms: maxEndpointDelayMs }),\n };\n}\n\n/** Команда Soniox «закрепи всё сказанное»: в ответ приходят закреплённые слова и метка `<fin>`; соединение остаётся открытым. */\nexport function buildFinalizeMessage() {\n return { type: \"finalize\" } as const;\n}\n\nexport type SonioxEvent =\n /** `finalized`: в ответе метка `<fin>`, то есть Soniox выполнил команду «закрепи всё» и всё сказанное закреплено. */\n | { type: \"text\"; text: DictatedText; finalized?: true; endedPhrases?: string[] }\n | { type: \"finished\"; text?: DictatedText; endedPhrases?: string[] }\n | { type: \"error\"; code: number; message: string }\n | { type: \"ignored\" };\n\n/** Служебные метки Soniox между словами: в текст не попадают. `<fin>` — конец закрепления по команде, `<end>` — конец фразы. */\nconst FINALIZED_MARK = \"<fin>\";\nconst PHRASE_END_MARK = \"<end>\";\nconst serviceTokens = new Set([FINALIZED_MARK, PHRASE_END_MARK]);\n\ninterface SonioxMessage {\n tokens?: Array<{ text?: unknown; is_final?: unknown }>;\n finished?: unknown;\n error_code?: unknown;\n error_message?: unknown;\n}\n\nexport interface TranscriptOptions {\n /**\n * Выдавать конец фразы: по метке `<end>` событие несёт `endedPhrases` — закреплённый текст каждой закончившейся фразы.\n * Нужно голосовому диалогу; диктовке поле не нужно, и без опции события прежние.\n */\n phraseEnds?: boolean | undefined;\n}\n\n/** Копит ответы Soniox: закреплённые слова только добавляются, незакреплённые каждый раз приходят заново целиком. */\nexport function createTranscript({ phraseEnds = false }: TranscriptOptions = {}) {\n let finalText = \"\";\n let phraseText = \"\";\n let interimText = \"\";\n const snapshot = (): DictatedText => ({ finalText, interimText });\n\n return {\n accept(raw: string): SonioxEvent {\n let message: SonioxMessage;\n try {\n message = JSON.parse(raw) as SonioxMessage;\n } catch {\n return { type: \"ignored\" };\n }\n if (typeof message !== \"object\" || message === null) return { type: \"ignored\" };\n\n if (message.error_code !== undefined || message.error_message !== undefined) {\n return {\n type: \"error\",\n code: typeof message.error_code === \"number\" ? message.error_code : 0,\n message: typeof message.error_message === \"string\" ? message.error_message : \"\",\n };\n }\n\n const hasTokens = Array.isArray(message.tokens);\n let finalized = false;\n const endedPhrases: string[] = [];\n if (hasTokens) {\n let interim = \"\";\n for (const token of message.tokens!) {\n if (typeof token.text !== \"string\") continue;\n if (token.text === FINALIZED_MARK) finalized = true;\n if (token.text === PHRASE_END_MARK && phraseEnds) {\n endedPhrases.push(phraseText.trim());\n phraseText = \"\";\n }\n if (serviceTokens.has(token.text)) continue;\n if (token.is_final === true) {\n finalText += token.text;\n phraseText += token.text;\n } else interim += token.text;\n }\n interimText = interim;\n }\n\n const phrases = endedPhrases.length > 0 ? { endedPhrases } : {};\n if (message.finished === true) {\n return hasTokens && message.tokens!.length > 0 ? { type: \"finished\", text: snapshot(), ...phrases } : { type: \"finished\" };\n }\n if (!hasTokens) return { type: \"ignored\" };\n return { type: \"text\", text: snapshot(), ...(finalized && { finalized: true as const }), ...phrases };\n },\n\n /** Диктовка закончилась: что Soniox не успел закрепить, остаётся как есть. */\n settle(): DictatedText {\n finalText += interimText;\n interimText = \"\";\n return snapshot();\n },\n\n current: snapshot,\n };\n}\n"],"mappings":";AAIO,IAAM,cAAc;AAAA,EACzB,QAAQ;AAAA;AAAA,EAER,eAAe;AAAA,EACf,UAAU;AAAA;AAAA,EAEV,cAAc;AAAA;AAAA,EAEd,OAAO;AAAA;AAAA,EAEP,WAAW;AAAA,EACX,WAAW;AAAA;AAAA,EAEX,SAAS;AAAA,EACT,aAAa;AAAA,EACb,UAAU;AACZ;;;ACQO,SAAS,kBAAkB,EAAE,QAAQ,OAAO,eAAe,SAAS,sBAAsB,mBAAmB,GAAwB;AAC1I,SAAO;AAAA,IACL,SAAS;AAAA,IACT;AAAA,IACA,cAAc;AAAA,IACd,gBAAgB;AAAA,IAChB,GAAI,WAAW,EAAE,QAAQ;AAAA,IACzB,2BAA2B;AAAA,IAC3B,GAAI,yBAAyB,UAAa,EAAE,mCAAmC,qBAAqB;AAAA,IACpG,GAAI,uBAAuB,UAAa,EAAE,uBAAuB,mBAAmB;AAAA,EACtF;AACF;AAGO,SAAS,uBAAuB;AACrC,SAAO,EAAE,MAAM,WAAW;AAC5B;AAUA,IAAM,iBAAiB;AACvB,IAAM,kBAAkB;AACxB,IAAM,gBAAgB,oBAAI,IAAI,CAAC,gBAAgB,eAAe,CAAC;AAkBxD,SAAS,iBAAiB,EAAE,aAAa,MAAM,IAAuB,CAAC,GAAG;AAC/E,MAAI,YAAY;AAChB,MAAI,aAAa;AACjB,MAAI,cAAc;AAClB,QAAM,WAAW,OAAqB,EAAE,WAAW,YAAY;AAE/D,SAAO;AAAA,IACL,OAAO,KAA0B;AAC/B,UAAI;AACJ,UAAI;AACF,kBAAU,KAAK,MAAM,GAAG;AAAA,MAC1B,QAAQ;AACN,eAAO,EAAE,MAAM,UAAU;AAAA,MAC3B;AACA,UAAI,OAAO,YAAY,YAAY,YAAY,KAAM,QAAO,EAAE,MAAM,UAAU;AAE9E,UAAI,QAAQ,eAAe,UAAa,QAAQ,kBAAkB,QAAW;AAC3E,eAAO;AAAA,UACL,MAAM;AAAA,UACN,MAAM,OAAO,QAAQ,eAAe,WAAW,QAAQ,aAAa;AAAA,UACpE,SAAS,OAAO,QAAQ,kBAAkB,WAAW,QAAQ,gBAAgB;AAAA,QAC/E;AAAA,MACF;AAEA,YAAM,YAAY,MAAM,QAAQ,QAAQ,MAAM;AAC9C,UAAI,YAAY;AAChB,YAAM,eAAyB,CAAC;AAChC,UAAI,WAAW;AACb,YAAI,UAAU;AACd,mBAAW,SAAS,QAAQ,QAAS;AACnC,cAAI,OAAO,MAAM,SAAS,SAAU;AACpC,cAAI,MAAM,SAAS,eAAgB,aAAY;AAC/C,cAAI,MAAM,SAAS,mBAAmB,YAAY;AAChD,yBAAa,KAAK,WAAW,KAAK,CAAC;AACnC,yBAAa;AAAA,UACf;AACA,cAAI,cAAc,IAAI,MAAM,IAAI,EAAG;AACnC,cAAI,MAAM,aAAa,MAAM;AAC3B,yBAAa,MAAM;AACnB,0BAAc,MAAM;AAAA,UACtB,MAAO,YAAW,MAAM;AAAA,QAC1B;AACA,sBAAc;AAAA,MAChB;AAEA,YAAM,UAAU,aAAa,SAAS,IAAI,EAAE,aAAa,IAAI,CAAC;AAC9D,UAAI,QAAQ,aAAa,MAAM;AAC7B,eAAO,aAAa,QAAQ,OAAQ,SAAS,IAAI,EAAE,MAAM,YAAY,MAAM,SAAS,GAAG,GAAG,QAAQ,IAAI,EAAE,MAAM,WAAW;AAAA,MAC3H;AACA,UAAI,CAAC,UAAW,QAAO,EAAE,MAAM,UAAU;AACzC,aAAO,EAAE,MAAM,QAAQ,MAAM,SAAS,GAAG,GAAI,aAAa,EAAE,WAAW,KAAc,GAAI,GAAG,QAAQ;AAAA,IACtG;AAAA;AAAA,IAGA,SAAuB;AACrB,mBAAa;AACb,oBAAc;AACd,aAAO,SAAS;AAAA,IAClB;AAAA,IAEA,SAAS;AAAA,EACX;AACF;","names":[]}
|
package/dist/hono.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// src/hono.ts
|
|
2
|
+
function mountVoiceRelay(app, path, relay, upgradeWebSocket) {
|
|
3
|
+
app.get(
|
|
4
|
+
path,
|
|
5
|
+
async (context, next) => {
|
|
6
|
+
const access = await relay.authorize(context.req.raw);
|
|
7
|
+
if ("refusal" in access) {
|
|
8
|
+
if (access.closeCode === void 0) return access.refusal;
|
|
9
|
+
context.set("voiceCloseCode", access.closeCode);
|
|
10
|
+
} else {
|
|
11
|
+
context.set("voiceUser", access.user);
|
|
12
|
+
context.set("voiceChatId", access.chatId);
|
|
13
|
+
}
|
|
14
|
+
await next();
|
|
15
|
+
},
|
|
16
|
+
upgradeWebSocket((context) => {
|
|
17
|
+
const user = context.get("voiceUser");
|
|
18
|
+
const chatId = context.get("voiceChatId");
|
|
19
|
+
const closeCode = context.get("voiceCloseCode");
|
|
20
|
+
let connection;
|
|
21
|
+
let queue = Promise.resolve();
|
|
22
|
+
return {
|
|
23
|
+
onOpen(_event, socket) {
|
|
24
|
+
if (closeCode !== void 0) return socket.close(closeCode);
|
|
25
|
+
connection = relay.accept({ send: (data) => socket.send(data), close: (code, reason) => socket.close(code, reason) }, user, chatId);
|
|
26
|
+
},
|
|
27
|
+
onMessage(event) {
|
|
28
|
+
const data = event.data;
|
|
29
|
+
queue = queue.then(async () => {
|
|
30
|
+
if (typeof data === "string") connection?.receive(data);
|
|
31
|
+
else if (data instanceof Blob) connection?.receive(new Uint8Array(await data.arrayBuffer()));
|
|
32
|
+
else if (ArrayBuffer.isView(data)) connection?.receive(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
|
33
|
+
else if (data instanceof ArrayBuffer) connection?.receive(data);
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
onClose: () => connection?.closed(),
|
|
37
|
+
onError: () => connection?.closed()
|
|
38
|
+
};
|
|
39
|
+
})
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
export {
|
|
43
|
+
mountVoiceRelay
|
|
44
|
+
};
|
|
45
|
+
//# sourceMappingURL=hono.js.map
|
package/dist/hono.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/hono.ts"],"sourcesContent":["import type { Context, Hono } from \"hono\";\nimport type { UpgradeWebSocket } from \"hono/ws\";\nimport type { RelayConnection, VoiceRelay } from \"./server/voice-relay\";\n\ntype VoiceRelayEnv = { Variables: { voiceUser: { id: string }; voiceChatId: string; voiceCloseCode: number | undefined } };\n\n/**\n * Подключает ретранслятор голоса к Hono: `GET {path}` поднимается до WebSocket.\n * `upgradeWebSocket` даёт адаптер вашего сервера, например `createNodeWebSocket` из `@hono/node-ws`.\n * Допуск решается до подъёма. Отказ с причиной (не вошёл, лимит, частота, сервис недоступен) браузер видит только кодом\n * закрытия: соединение поднимается и сразу закрывается этим кодом. Чужая страница — обычный HTTP-ответ 403.\n */\nexport function mountVoiceRelay(app: Hono, path: string, relay: VoiceRelay, upgradeWebSocket: UpgradeWebSocket): void {\n (app as unknown as Hono<VoiceRelayEnv>).get(\n path,\n async (context, next) => {\n const access = await relay.authorize(context.req.raw);\n if (\"refusal\" in access) {\n if (access.closeCode === undefined) return access.refusal;\n context.set(\"voiceCloseCode\", access.closeCode);\n } else {\n context.set(\"voiceUser\", access.user);\n context.set(\"voiceChatId\", access.chatId);\n }\n await next();\n },\n upgradeWebSocket((context: Context<VoiceRelayEnv>) => {\n const user = context.get(\"voiceUser\");\n const chatId = context.get(\"voiceChatId\");\n const closeCode = context.get(\"voiceCloseCode\");\n let connection: RelayConnection | undefined;\n // Куски звука и «конец звука» должны дойти по порядку, даже если платформа отдаёт их вразнобой (Blob).\n let queue: Promise<void> = Promise.resolve();\n return {\n onOpen(_event, socket) {\n if (closeCode !== undefined) return socket.close(closeCode);\n connection = relay.accept({ send: (data) => socket.send(data), close: (code, reason) => socket.close(code, reason) }, user, chatId);\n },\n onMessage(event) {\n const data = event.data as unknown;\n queue = queue.then(async () => {\n if (typeof data === \"string\") connection?.receive(data);\n else if (data instanceof Blob) connection?.receive(new Uint8Array(await data.arrayBuffer()));\n else if (ArrayBuffer.isView(data)) connection?.receive(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));\n else if (data instanceof ArrayBuffer) connection?.receive(data);\n });\n },\n onClose: () => connection?.closed(),\n onError: () => connection?.closed(),\n };\n }),\n );\n}\n"],"mappings":";AAYO,SAAS,gBAAgB,KAAW,MAAc,OAAmB,kBAA0C;AACpH,EAAC,IAAuC;AAAA,IACtC;AAAA,IACA,OAAO,SAAS,SAAS;AACvB,YAAM,SAAS,MAAM,MAAM,UAAU,QAAQ,IAAI,GAAG;AACpD,UAAI,aAAa,QAAQ;AACvB,YAAI,OAAO,cAAc,OAAW,QAAO,OAAO;AAClD,gBAAQ,IAAI,kBAAkB,OAAO,SAAS;AAAA,MAChD,OAAO;AACL,gBAAQ,IAAI,aAAa,OAAO,IAAI;AACpC,gBAAQ,IAAI,eAAe,OAAO,MAAM;AAAA,MAC1C;AACA,YAAM,KAAK;AAAA,IACb;AAAA,IACA,iBAAiB,CAAC,YAAoC;AACpD,YAAM,OAAO,QAAQ,IAAI,WAAW;AACpC,YAAM,SAAS,QAAQ,IAAI,aAAa;AACxC,YAAM,YAAY,QAAQ,IAAI,gBAAgB;AAC9C,UAAI;AAEJ,UAAI,QAAuB,QAAQ,QAAQ;AAC3C,aAAO;AAAA,QACL,OAAO,QAAQ,QAAQ;AACrB,cAAI,cAAc,OAAW,QAAO,OAAO,MAAM,SAAS;AAC1D,uBAAa,MAAM,OAAO,EAAE,MAAM,CAAC,SAAS,OAAO,KAAK,IAAI,GAAG,OAAO,CAAC,MAAM,WAAW,OAAO,MAAM,MAAM,MAAM,EAAE,GAAG,MAAM,MAAM;AAAA,QACpI;AAAA,QACA,UAAU,OAAO;AACf,gBAAM,OAAO,MAAM;AACnB,kBAAQ,MAAM,KAAK,YAAY;AAC7B,gBAAI,OAAO,SAAS,SAAU,aAAY,QAAQ,IAAI;AAAA,qBAC7C,gBAAgB,KAAM,aAAY,QAAQ,IAAI,WAAW,MAAM,KAAK,YAAY,CAAC,CAAC;AAAA,qBAClF,YAAY,OAAO,IAAI,EAAG,aAAY,QAAQ,IAAI,WAAW,KAAK,QAAQ,KAAK,YAAY,KAAK,UAAU,CAAC;AAAA,qBAC3G,gBAAgB,YAAa,aAAY,QAAQ,IAAI;AAAA,UAChE,CAAC;AAAA,QACH;AAAA,QACA,SAAS,MAAM,YAAY,OAAO;AAAA,QAClC,SAAS,MAAM,YAAY,OAAO;AAAA,MACpC;AAAA,IACF,CAAC;AAAA,EACH;AACF;","names":[]}
|