npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ykurilov/agent-chat-voice-dialog

v0.75.0

Published

Голосовой режим чата с агентом: человек говорит, агент отвечает голосом, человека можно перебить. Распознавание — Soniox (через пакет `@ykurilov/agent-chat-voice`), озвучка — ElevenLabs. Ключи хранятся только на сервере: в браузер не уходит ни главный, ни

Readme

@ykurilov/agent-chat-voice-dialog

Голосовой режим чата с агентом: человек говорит, агент отвечает голосом, человека можно перебить. Распознавание — Soniox (через пакет @ykurilov/agent-chat-voice), озвучка — ElevenLabs. Ключи хранятся только на сервере: в браузер не уходит ни главный, ни временный ключ.

Отдельное расширение: приложению без голоса пакет не нужен, а чат без него кнопки режима не рисует. Диктовка в поле ввода (пакет voice) остаётся как была и работает рядом.

Что делает режим: слова человека сами уходят сообщением в чат, ответ агента идёт в ленту и звучит, перебивание обрывает звук сразу и оставляет в ленте отметку «Вы перебили: озвучено до «…»» (она хранится и видна после перезагрузки). Расход на озвучку попадает в учёт и в лимиты (source: "speech").

Сервер (Hono)

import { createNodeWebSocket } from "@hono/node-ws";
import { mountDialogRelay } from "@ykurilov/agent-chat-voice-dialog/hono";

const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app });

mountDialogRelay(app, {
  path: "/api/voice-dialog",        // WebSocket: {path}/stream; голоса: GET {path}/voices, PUT {path}/voice; заполнители: GET {path}/fillers
  upgradeWebSocket,
  storage,                          // то же хранилище, что у чата
  resolveUser: async (request) => currentUser(request), // тот же вход, что у чата; нет человека → 4401
  spendingLimits,                   // по желанию: те же лимиты, что у чата (пакет limits)
  logError: (message) => console.error(message),
  allowedOrigins: ["https://school.getcourse.ru"], // по желанию: чужие страницы, которым можно открывать режим (схема, хост, порт); свой сайт проходит всегда
  speech: {
    voices: [{ id: "oE0n0S3wK5Mj9C62iehn", name: "Профессиональный диктор для аудиокниг" }],
    defaultVoiceId: "oE0n0S3wK5Mj9C62iehn",
    pricePerThousandChars: { eleven_v4_turbo: 0.11, eleven_v4: 0.22 }, // $ за 1000 знаков, по вашему тарифу
  },
  // ключи: ELEVENLABS_API_KEY и SONIOX_API_KEY из окружения сервера (или apiKey: "…" в speech / recognition)
});
injectWebSocket(server); // тот же порт, что у остального сервера

Без speech режим только слушает и пишет (озвучки нет, голосовое меню не показывается). Из России сервер ходит к сервисам через прокси: recognition: { baseUrl, headers }, speech: { baseUrl, headers } и sounds: { baseUrl, headers }.

Настройки сервера

| Где | Настройка | По умолчанию | |---|---|---| | speech | model | eleven_v4_turbo (быстрый, вдвое дешевле eleven_v4) | | speech | defaultVoiceId | обязателен; в демо — «Профессиональный диктор для аудиокниг» oE0n0S3wK5Mj9C62iehn | | speech | language | ru | | speech | fillerPhrases | свои фразы-заполнители по видам (см. «Заполнители») или false — заполнителей нет | | speech | maxCharsPerReply, maxCharsPerMinute | 2000, 4000: защита от дорогих ответов. Минутный потолок общий для всех вкладок человека | | recognition | language, model | ru, stt-rt-v5 | | recognition | endpointLatencyLevel, maxEndpointDelayMs | 2, 1500: как быстро Soniox считает фразу законченной | | recognition | context | нет: подсказка распознаванию (general, text, terms, translation_terms), как у диктовки; Soniox берёт до 8000 токенов | | корень | allowedOrigins | нет: проходит только свой сайт. Чужая страница (не из списка) получает 403 forbidden_origin до подъёма сокета и при смене голоса. Запрос не из браузера (без Origin и Sec-Fetch-Site) проходит, его судит resolveUser | | корень | idleSeconds | 180: тишина дольше — предупреждение, затем закрытие | | корень | maxSessionSeconds | 1800: самое долгое включение | | корень | rateLimit | 10 включений в минуту на человека; false — без ограничения | | корень | maxConnectionsPerUser | 2; false — без ограничения |

Звук с микрофона ограничен реальным временем с запасом в полтора раза (в среднем до 48 000 байт в секунду за окно 10 секунд): больше — соединение закрывается кодом 4413. Человека он не касается, так что нормальный микрофон до него не доходит.

Цена за 1000 знаков в коде нужна для лимитов расходов: поставьте свою. Цифры в демо (0,11 $) предварительные.

Next.js и другие серверы без Hono

В обработчиках маршрутов Next.js WebSocket не поднимается: нужен свой Node-сервер рядом (например, на ws). Ядро режима не зависит от Hono:

import { createDialogRelay } from "@ykurilov/agent-chat-voice-dialog/server";

const relay = createDialogRelay({ storage, resolveUser, logError, speech, recognition });
// на каждое соединение WebSocket:
const connection = relay.accept({ send: (data) => socket.send(data), close: (code) => socket.close(code) }, request);
socket.on("message", (data) => connection.receive(data)); // текст (JSON) или кусок звука
socket.on("close", () => connection.closed());
// HTTP голосов: relay.voices (каталог) — подключите GET …/voices и PUT …/voice через mountVoiceRoutes или свои маршруты

request — стандартный Request с куками соединения: по нему resolveUser решает, кто это.

Браузер

import { createVoiceDialogEngine } from "@ykurilov/agent-chat-voice-dialog";

const voiceDialog = createVoiceDialogEngine({ basePath: "/api/voice-dialog" });

<AgentChat api="/api/chat" skin={tandemSkin} voiceDialog={voiceDialog} />;

Кнопка режима появляется в поле ввода; пока режим включён, поле ввода скрыто, вместо него панель с состоянием и кнопкой «Выйти». Движок один на приложение. Скин нужен версии 10 или выше (Тандем из этого же выпуска).

Настройки движка

| Настройка | Что делает | |---|---| | basePath | общий путь режима на сервере (тот же path); обязателен | | bargeIn.stopWords | слова, которые перебивают всегда | | bargeIn.ignoreWords | слова-междометия, которые одиночкой не перебивают («ага», «угу») | | bargeIn.minConfidence | слово с меньшей уверенностью распознавания не слушаем (0,6) | | bargeIn.echoWindowMs | сколько мс сказанное агентом считается эхом (5000) | | language | язык распознавания, уходит серверу (ru) | | fillers | заполнители (см. «Заполнители»): false — выключены; settings — пороги и правила | | onMetrics | получает замер задержки по каждому ходу; никуда не отправляется |

Слова остановки и междометия по умолчанию только русские («стоп», «подожди», «хватит»… и «угу», «ага», «ну»…). Для другого языка задайте свои bargeIn.stopWords и bargeIn.ignoreWords, иначе эхо и «мм» будут считаться перебиванием, а настоящее «stop» — нет. Язык распознавания и озвучки меняется отдельно (language на сервере и в движке).

Заполнители

Пока модель молчит, размышляет или вызывает инструмент, голос говорит короткую фразу тем же голосом, что и ответ: «Хмм…», «Дай-ка подумаю…», «Сейчас поищу в интернете.», «Сейчас прочту, что там.», «Дай-ка вспомню.», «Ещё чуть-чуть…». В чат фраза не попадает и модели не уходит. Включено само, если настроена озвучка; ничего подключать не нужно.

Когда звучит (пороги по умолчанию, настройка fillers.settings в движке):

| Что происходит | Через сколько | Настройка | |---|---|---| | человек договорил, модель молчит | 1,5 с | thinkingAfterMs | | модель размышляет | 2,5 с | reasoningAfterMs | | идёт вызов инструмента | 0,7 с | toolAfterMs | | после первого заполнителя ответа всё нет | 6 с | longAfterMs |

Не больше двух заполнителей на ход (maxPerTurn), между ними не меньше 2 с (minGapMs), одну и ту же фразу подряд не повторяем (если у вида одна фраза, она звучит снова: убирать повтор можно, только когда есть из чего выбрать). Пока звучит настоящий ответ, заполнитель молчит, а после него ждёт 1,5 с тишины (quietAfterAnswerMs): так модель, сказавшая «сейчас поищу» и замолчавшая, не получает второго «сейчас поищу». Закончился инструмент (или размышление), а текста ответа всё нет — тишина считается заново, и через те же 1,5 с звучит «думает» (в пределах двух на ход). Пошёл текст ответа — заполнителей больше нет. Перебивание глушит заполнитель, как и ответ. Настоящий ответ встаёт в очередь сразу за заполнителем: без обрыва посреди слова и без лишней паузы.

Виды фраз: thinking (молчит), reasoning (размышляет), web (web_search), page (read_page, page_screenshot), memory (manage_memory, search_earlier_conversation), tool (любой другой инструмент), long («ещё чуть-чуть»). Свой инструмент привяжите к виду: fillers: { settings: { toolCategories: { my_lookup: "web" } } }.

Свои фразы задаются на сервере: speech.fillerPhrases: { web: ["Ищу в сети."], long: [] } (вид из списка заменяет умолчание целиком, пустой список убирает вид). fillerPhrases: false на сервере или fillers: false в движке выключают заполнители совсем.

Звук и расход. Фразы синтезируются заранее, выбранным голосом, той же моделью ElevenLabs, что и озвучка. Первый синтез каждой фразы выбранным голосом запускает тот, чей браузер её запросил при входе в режим или выборе голоса; звук хранится в памяти сервера (до перезапуска) и дальше отдаётся всем бесплатно. Расход этого первого синтеза записывается как озвучка (source: "speech") на этого человека и его чат; лимит расходов проверяется у него до синтеза. Причина: учёту расходов нужен владелец и чат, а общих «системных» записей в нём нет; сумма небольшая: умолчания (15 фраз, около 275 знаков) стоят около 0,03 $ на голос при цене 0,11 $ за 1000 знаков. Браузер подгружает звук в фоне: фраза, которая не успела загрузиться или не получилась (лимит, сбой), просто пропускается, ход идёт как обычно. Не больше трёх синтезов разом, после сбоя фразу не пробуем заново 30 секунд. Список голосов не получился — фразы грузятся голосом, который сервер назвал при входе в режим. У ElevenLabs число одновременных соединений ограничено тарифом, а заполнители берут до трёх синтезов разом: при первом входе с новым голосом они могут ненадолго занять часть этого лимита (повторные входы идут из памяти сервера и лимит не трогают).

Чтобы модель могла объявлять размышление и вызовы инструментов, чату нужен мост из пакета react (он есть в useVoiceDialog, ничего подключать не нужно). Свой мост без subscribeAssistantActivity получит только заполнитель «молчит».

Склейка фраз

Человек сказал «Так.», а пока модель думает, добавил «Дай-ка подумать.». Две фразы не превращаются в два хода: ход останавливается, вместо прежнего сообщения уходит одно, склеенное («Так. Дай-ка подумать.»), и модель отвечает один раз. В видимой истории нет ни отдельного «Так.», ни пустого оборванного ответа. Третья фраза дописывается так же.

Склеивается, только пока у хода нет ни текста ответа, ни звука, ни начатого вызова инструмента. Размышление модели и заполнитель («Хмм…») склейке не мешают. Прежнее поведение (ход перебит, новая фраза уходит отдельным сообщением) остаётся, когда:

  • текст или звук ответа уже пошёл;
  • модель начала вызов инструмента: это действие, его не отменить, и модель должна о нём знать;
  • человек сказал стоп-слово («стоп», «подожди») или два междометия подряд: он и хотел остановить;
  • чат не смог склеить (сервер отказал) или у моста нет replaceLastMessage.

Склейка идёт через правку последнего сообщения человека: старое сообщение и пустой ответ остаются в базе, но склеенное сообщение их заменяет (отметка replaces в его data-voice-mode), поэтому переключателя веток у него нет и пролистать на обрывок нельзя. Если человек потом сам поправит склеенное сообщение, появится настоящая развилка, обрывков в ней не будет. У склеенного сообщения остаются отметка голосового режима, голос и подсказки режима, а начало запроса к модели не меняется, так что кэш модели цел. Склеенные до этого обновления сообщения переключатель сохраняют: у них нет отметки replaces. Остановленный ход пишет свой расход, как любая остановка. Название чата, выведенное из первого сообщения, обновляется до склеенной фразы.

Нужен мост чата с replaceLastMessage (в useVoiceDialog он есть). Сервер должен принимать voiceMode в POST …/edit (@ykurilov/agent-chat-server той же версии).

Что модель узнаёт о режиме

Сообщение, отправленное из режима, запоминается голосовым: в его parts лежит часть data-voice-mode (экран её не показывает; свой скин должен пропускать части, которых не знает). Модель получает две служебные пометки, каждую отдельной текстовой частью в конце сообщения человека и только при смене режима:

  • при входе в режим, у первого голосового сообщения подряд, — «ответы озвучиваются: коротко, разговорно, без списков и разметки» (agent.voiceModeHint у createChatService);
  • при возврате к тексту, у первого текстового сообщения после голосового, — «разговор снова текстом, ответ не озвучивается» (agent.textModeNote).

Пометки выводятся из сохранённых отметок и в историю чата не пишутся, поэтому начало запроса от хода к ходу не меняется и кэш модели цел. У чата, где голосовой режим не включали, запрос прежний.

Звуки человека

Смех, вздох, кашель и похожие звуки режим замечает и передаёт модели: человек смеётся над шуткой, агент отвечает «ха, рад, что смешно». Слова по-прежнему даёт только Soniox. Звуки ловит ElevenLabs Scribe (scribe_v2, запрос с tag_audio_events=true), и только как детектор звуков: его слова не читаются. Ключ остаётся на сервере: звук идёт с микрофона через наш сервер, браузер к Scribe не ходит.

mountDialogRelay(app, {
  // …
  sounds: { pricePerHour: 0.22 }, // обязательная цена за час звука; ключ — тот же ELEVENLABS_API_KEY
});

| Настройка sounds | По умолчанию | |---|---| | pricePerHour | обязательна: доллары за час звука; Scribe v2 стоит 0,22 $ по документации ElevenLabs, цену сверяйте с прайсом | | model | scribe_v2 | | baseUrl, headers | https://api.elevenlabs.io, без своих заголовков. Из России — адрес прокси и { "X-Proxy-Token": … }; ключ ElevenLabs заголовки не заменяют | | waitMs | 1000: сколько ждать на конце фразы ответ Scribe на хвост звука и на куски, которые ещё не вернулись. Не успел — фраза уходит без них, а звуки приклеиваются к следующей | | chunkSeconds | 1,5: каждые столько секунд нового звука во время фразы уходит кусок в Scribe. 0 — звук уходит целиком на конце фразы | | overlapSeconds | 0,5: сколько прежнего звука повторяется в начале следующего куска (смех на стыке не разрезается) | | silenceRms | 0,01: кусок тише этого (уровень от 0 до 1) считается тишиной и не отправляется. 0 — не проверять | | maxWindowSeconds | 30: сколько звука уходит в Scribe за раз | | timeoutMs | 5000: дольше запрос обрывается | | apiKey, env, baseUrl, language | ключ из env.ELEVENLABS_API_KEY, ru |

Как это работает:

  • Куски звука. Пока человек говорит, сервер каждые chunkSeconds (1,5 с) нового звука отправляет в Scribe очередной кусок, с перекрытием в overlapSeconds (0,5 с), чтобы смех на стыке не разрезался; на конце фразы уходит только хвост. Звуки фразы — объединение ответов всех её кусков, повторы на перекрытии считаются один раз. Тишина не отправляется. На конце фразы ждём только хвост и не вернувшиеся куски, не дольше waitMs: успел — звуки идут в самом phrase_end, не успел — фраза уходит без них, а звуки догоняют отдельным сообщением sounds и приклеиваются к следующей фразе (старше 30 секунд не клеятся). Замер живым вызовом: Scribe отвечает на 1,5–2 с звука за 0,7–0,95 с, поэтому порог по умолчанию 1000 мс; если ответ агента важнее звука в хвосте фразы, уменьшите waitMs. Запросов к Scribe одновременно не больше двух: лишний кусок пропускается, его звук уйдёт со следующим. Расход считается по фактически отправленным секундам, включая перекрытия: на минуту разговора, где человек говорит 15 с, уходит порядка 20 с звука (≈ $0,0013 при цене 0,22 $/час); если динамик агента слышен микрофону выше порога тишины — до 50 с.
  • Один смех без слов ход модели не запускает: Soniox слов не дал, конца фразы нет. Звук остаётся в окне и уходит вместе со следующей фразой.
  • Что видит модель. Звуки лежат в самом сообщении человека, в части data-voice-mode (data: { sounds: ["laughter"] }), и уходят на сервер как voiceSounds вместе с voiceMode. Пометка («пока человек говорил, были слышны его звуки: смех») выводится из сохранённого при сборке запроса, как остальные пометки режима, поэтому уже отправленные сообщения не меняются и кэш модели цел. В пометке входа есть короткое правило, как на неё реагировать. На экране звуков нет. Склейка фраз сохраняет звуки обеих.
  • Эхо. Смех или вздох самого агента (тег «[laughs]», заполнитель, «Ха-ха» в озвученном тексте) может попасть в микрофон. Звук того же вида, пойманный в окне от 1,5 с до 2,5 с вокруг момента, когда агент его издал, звуком человека не считается.
  • Сбои. Ошибка, таймаут, нет ключа, исчерпанный лимит расходов — режим работает без звуков, в журнал идёт предупреждение (про ключ — один раз). Одновременно к Scribe идёт не больше двух запросов.
  • Расходы. Каждый ответивший запрос пишет запись source: "sounds" (providerId: "elevenlabs", inputTokens — секунды звука, округлённые вверх), она входит в суммы для лимитов расходов.
  • Названия звуков. Scribe называет звуки по языку запроса: при ru — «[смешок]», без языка — «(laughter)». Парсер знает оба написания; виды — закрытый список HUMAN_SOUND_KINDS (смех, вздох, кашель, чих, резкий вдох, плач, зевок, стон), остальное отбрасывается.

В демо: DEMO_SOUNDS=off выключает звуки, DEMO_SOUNDS_PRICE_PER_HOUR меняет цену.

Теги голоса

Eleven v4 отыгрывает теги в квадратных скобках: «[laughs]», «[sighs]», «[whispers]», «[excited]», «[warm, quiet voice]». Пометка входа в режим (voiceModeHint) перечисляет теги и просит ставить их по-английски, к месту, не в каждой фразе, в начале предложения или внутри него, никогда не одиночкой; паузу показывать многоточием. Пометка возврата к тексту (textModeNote) просит больше теги не ставить, даже если они есть выше в истории. Если вы задали свои voiceModeHint и textModeNote, теги в них добавьте сами; озвучка и очистка работают и без этого.

Что делает пакет с тегами:

  • Озвучка. Тег не становится отдельным предложением: он приклеивается к соседнему тексту, а тег в конце ответа («Да. [laughs]») — к последнему предложению, и смех в конце звучит (чтобы это успеть, предложение, за которым идут одни теги, ждёт, чем кончится дело). Тег на своей последней строке, когда предложение до него уже ушло с прежней строкой, не озвучивается. Текст, где кроме тегов нет ни буквы, ни цифры, на сервер озвучки не уходит: одиночный тег ElevenLabs отвергает (input_text_empty, код 1008) и обрывает всю реплику. Курсив вокруг тега («[laughs]», «[sighs]») и переводы строк Windows (CRLF) озвучка и экран понимают одинаково. Позиция обрыва считается по исходному тексту, в цитате озвученного тегов нет.
  • Эхо. Фильтр перебивания получает текст без тегов и звуки, которые тег даёт голосу. Звуки сверяются по шаблону, а не по списку: смех («ха», «ха-ха-ха-ха», «хи-хи», «хе»), вздох и выдох («эх», «ох», «ээх», «оох», «ах», «фух»), «хм», кашель, зевок. Так смех агента в микрофоне перебиванием не считается. Слова составных заполнителей («Дай-ка», «чуть-чуть») сверяются по частям, разбитым по дефису, и в озвученном, и в услышанном. Скорость речи для сверки считается без тегов.
  • Экран. В ответах голосовых ходов теги не видны: ни в тексте ответа (недописанный в потоке тоже), ни при копировании и объявлении ленты, ни в поиске по прошлым разговорам (@ykurilov/agent-chat-history-search). Голосовой ход — тот, где сообщение человека несёт отметку data-voice-mode: её ставит sendVoiceMessage сразу (поэтому живой ход и перезагруженная страница ведут себя одинаково), ядро записывает её в историю. В ответах текстового режима и в сводке сжатия скобки остаются как есть: там «[docs]» и «[error]» — обычные слова, а сводка пишется один раз и переписывается необратимо. Для модели в истории теги остаются: кэш цел. Побочный эффект: в голосовом ходе скобки, подходящие под правило («[Google]», «arr[index]»), тоже исчезнут из показа. Функции messageText (react) и searchableText (history-search) получили параметр { voiceAnswer }; isVoiceMessage экспортируется из core.

Свой набор тегов у каждого голоса

Голосам идут разные теги: хрип и смех украшают «Старого пирата», а диктору аудиокниг с придыханием «[softly]» не по себе. В описании голоса (speech.voices) можно задать необязательный набор. Без него голосу идёт общий список.

const voices = [
  { id: "oE0n0S3wK5Mj9C62iehn", name: "Диктор аудиокниг" }, // без набора: общий список
  {
    id: "JyEaew7H7mwhutMPHSNz",
    name: "Старый пират",
    tags: {
      allowed: ["laughs", "wheezing", "sighs", "low, gravelly voice"], // идут голосу; описательные теги — сюда же
      avoid: ["whispers", "softly"],                                   // не идут (по желанию)
    },
  },
];

Модель получает список текущего голоса в пометке входа в режим. Теги проверяются при запуске по тому же правилу, что и на экране: «смеётся» или «[laughs]» в скобках дают понятную ошибку с названием голоса. Набор экрану не отдаётся.

Чату нужно знать голос человека, а каталог голосов строит пакет режима. Поэтому создайте каталог один раз и передайте его и режиму, и чату:

import { createVoiceCatalog } from "@ykurilov/agent-chat-voice-dialog/server";

const catalog = createVoiceCatalog({ voices, defaultVoiceId: voices[0].id, storage });

createChatHandler({ /* … */ agent: { instructions, voices: catalog } });
mountDialogRelay(app, { /* … */ speech: { catalog, pricePerThousandChars } }); // вместо voices и defaultVoiceId

Без agent.voices у всех голосов общий список, как раньше. Голос человека на момент отправки сохраняется на его сообщении в части data-voice-mode ({ voice: "id" }, без миграции базы).

Если голос сменился посреди голосового разговора, первое сообщение с новым голосом один раз получает короткую служебную пометку с его тегами («теги не из этого списка больше не ставь, даже если они есть выше в истории»). Голоса с одинаковым списком (например, оба без набора) пометки не получают. Как и остальные пометки, она выводится из сохранённых отметок и в историю не пишется: повтор ответа, правка, перезагрузка и возврат к тексту строят то же начало запроса, кэш модели цел. Если задан свой voiceModeHint (строка), он заменяет пометку целиком: пометок о тегах и смене голоса тогда нет, список тегов в нём приложение пишет само.

Тег — латинские буквы, пробелы, запятые и дефисы (строчные или слово с заглавной буквы: «[Laughs]»), от 3 до 40 знаков, не больше шести слов; допустим закрывающий «[/whispers]». Тег впритык к слову («ну[sighs]») тоже тег. Остальные скобки («[1]», «[sic]», «[TODO]», «[смеётся]», ссылки и сноски Markdown, чекбоксы, код) остаются. Правило живёт в двух копиях (core и react: экран ядра не знает, а ядру не нужен браузерный код); их согласие держит тест на широком наборе. Свой скин, который показывает текст ответа сам, может взять stripVoiceTags из @ykurilov/agent-chat-react, но вырезать должен только у ответов голосовых ходов (voiceAnswerIds).

При выходе версии с новыми пометками модель один раз получает другое начало запроса, и кэш модели у уже идущих голосовых чатов промахивается один раз; дальше всё как раньше.

Политика содержимого страницы

Захват звука идёт через AudioWorklet, модуль которого собирается из строки и грузится через Blob: отдельного файла для сборщика нет. Если у приложения строгая политика содержимого (Content-Security-Policy), добавьте worklet-src blob: (или script-src blob:, если worklet-src не задан). Соединение с сервером — обычный WebSocket на ваш же сайт: connect-src 'self' достаточно (wss: для https).

Голосовые ответы без экрана (мессенджеры)

Не только режим в браузере: createVoiceReplies озвучивает один готовый ответ и отдаёт файл audio/ogg (формат голосовых сообщений Telegram, VK и MAX). Его берёт createMessengers({ voiceReplies }).

import { createVoiceReplies } from "@ykurilov/agent-chat-voice-dialog/server";

const voiceReplies = createVoiceReplies({
  apiKey: process.env.ELEVENLABS_API_KEY!,
  voiceId: "JBFqnCBsd6RMkjVDRZzb",
  model: "eleven_v4_turbo",           // или "eleven_v4"
  language: "ru",
  logError: (message) => console.error(message),
  storage,                            // тот же, что у чата: расход пишется как source "speech"
  pricePerThousandChars: { eleven_v4_turbo: 0.11, eleven_v4: 0.22 }, // $ за 1000 знаков, по вашему тарифу
  spendingLimits,                     // по желанию: те же лимиты, что у чата
});

const result = await voiceReplies.speak({ text, ownerId, chatId, messageId, signal });
// { audio, mediaType: "audio/ogg", durationMs }  или  { skipped: "limit" | "unavailable" }

| Настройка | Что | По умолчанию | |---|---|---| | maxChars | Сколько знаков озвучить; дальше фраза «Дальше на экране.» | 1500 | | baseUrl, headers | Прокси к ElevenLabs | прямое соединение |

Обязательны apiKey, voiceId, model, language, storage, logError (ключи в ошибках прячутся) и цена pricePerThousandChars для выбранной модели.

Код, таблицы и схемы вслух не читаются. Лимит расходов исчерпан или сервис недоступен — skipped, и мессенджер шлёт обычный текст. Платят знаки, отправленные в ElevenLabs, даже если озвучку оборвали.

Сколько стоит. Живая проверка (tests/live/voice-replies.live.test.ts, платная, сначала печатает оценку и не идёт дороже $0,5): ответ примерно в 438 знаков на eleven_v4_turbo — около 30 секунд речи и ≈ $0,048; четыре таких ответа — ≈ $0,19. На ответ уходит 9–10 секунд работы.

База данных

Пакет хранит голос человека и отметку обрыва. SQLite получает миграцию 0008, PostgreSQL — 0007 (схема agent_chat) сами при открытии базы. Своему хранилищу новые методы нужны по желанию: setVoiceCut, getVoiceChoice, setVoiceChoice (без них отметка и выбор голоса живут только до перезагрузки страницы). Проверка — describeChatStorageContract.

Замер задержки

Медиана «накладные расходы SDK» (от первого предложения ответа до первого звука, на подменах Soniox и ElevenLabs) не должна быть больше 400 мс на 8 ходах:

pnpm --filter @ykurilov/agent-chat-demo test:e2e:dialog-latency

Команда печатает разбивку по звеньям: всего, модель, предложение, озвучка, доставка. В обычный прогон тестов она не входит. Настоящие сервисы меряются отдельно (этап 7 плана specs/016-voice-dialog).

Демо с настоящими сервисами

pnpm secrets:load   # кладёт ключи из ~/.codex/secrets и ~/.config/soniox в .env.local (значения не печатает)
pnpm dev            # сервер читает ключи из окружения процесса; браузеру они не отдаются