@voicethere/agent 0.9.2 → 0.9.3

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.
@@ -952,6 +952,50 @@ var ECHO_PREFIXES = {
952
952
  pl: "Powiedzia\u0142e\u015B:",
953
953
  ru: "\u0412\u044B \u0441\u043A\u0430\u0437\u0430\u043B\u0438:"
954
954
  };
955
+ var WAIT_MESSAGES = {
956
+ en: "One moment please, I'm switching to your language.",
957
+ de: "Einen Moment bitte, ich wechsle zu Ihrer Sprache.",
958
+ es: "Un momento, por favor, estoy cambiando a su idioma.",
959
+ fr: "Un instant, je passe dans votre langue.",
960
+ it: "Un momento, per favore, sto passando alla tua lingua.",
961
+ pt: "Um momento, por favor, estou mudando para o seu idioma.",
962
+ nl: "Een moment alstublieft, ik schakel over naar uw taal.",
963
+ pl: "Chwileczk\u0119, prze\u0142\u0105czam si\u0119 na Tw\xF3j j\u0119zyk.",
964
+ ru: "\u041E\u0434\u043D\u0443 \u043C\u0438\u043D\u0443\u0442\u0443, \u044F \u043F\u0435\u0440\u0435\u0445\u043E\u0436\u0443 \u043D\u0430 \u0432\u0430\u0448 \u044F\u0437\u044B\u043A."
965
+ };
966
+ var FAILED_MESSAGES = {
967
+ en: "Sorry, I couldn't switch languages. I'll keep going in English.",
968
+ de: "Entschuldigung, der Sprachwechsel hat nicht geklappt. Ich bleibe bei Deutsch.",
969
+ es: "Lo siento, no pude cambiar de idioma. Sigo en espa\xF1ol.",
970
+ fr: "D\xE9sol\xE9, je n'ai pas pu changer de langue. Je continue en fran\xE7ais.",
971
+ it: "Mi dispiace, non sono riuscito a cambiare lingua. Continuo in italiano.",
972
+ pt: "Desculpe, n\xE3o consegui mudar de idioma. Continuo em portugu\xEAs.",
973
+ nl: "Sorry, het wisselen van taal is niet gelukt. Ik blijf Nederlands spreken.",
974
+ pl: "Przepraszam, nie uda\u0142o si\u0119 zmieni\u0107 j\u0119zyka. Zostaj\u0119 przy polskim.",
975
+ ru: "\u0418\u0437\u0432\u0438\u043D\u0438\u0442\u0435, \u043D\u0435 \u0443\u0434\u0430\u043B\u043E\u0441\u044C \u0441\u043C\u0435\u043D\u0438\u0442\u044C \u044F\u0437\u044B\u043A. \u041F\u0440\u043E\u0434\u043E\u043B\u0436\u0430\u044E \u043F\u043E-\u0440\u0443\u0441\u0441\u043A\u0438."
976
+ };
977
+ var WAIT_MESSAGES_ENV = "LANGUAGE_SWITCH_WAIT_MESSAGES_JSON";
978
+ var FAILED_MESSAGES_ENV = "LANGUAGE_SWITCH_FAILED_MESSAGES_JSON";
979
+ function parseMessageOverrides(raw) {
980
+ if (!raw) return {};
981
+ let parsed;
982
+ try {
983
+ parsed = JSON.parse(raw);
984
+ } catch {
985
+ return {};
986
+ }
987
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
988
+ const out = {};
989
+ for (const [key, value] of Object.entries(parsed)) {
990
+ if (typeof value === "string" && value.trim()) {
991
+ out[key.trim().toLowerCase()] = value.trim();
992
+ }
993
+ }
994
+ return out;
995
+ }
996
+ function pickMessage(defaults, overrides, language) {
997
+ return overrides[language] ?? defaults[language] ?? overrides.en ?? defaults.en;
998
+ }
955
999
  var REPLIES = {
956
1000
  de: "Guten Tag. Ich antworte jetzt auf Deutsch.",
957
1001
  es: "Hola. Ahora respondo en espa\xF1ol.",
@@ -970,7 +1014,11 @@ function stateFor(sessionId, env) {
970
1014
  language: "en",
971
1015
  runnerAutoSwitch: env ? isRunnerLidAutoSwitchEnabled(env) : false,
972
1016
  echoTimer: void 0,
973
- suppressNextFinal: false
1017
+ suppressNextFinal: false,
1018
+ pendingSwitch: void 0,
1019
+ switching: false,
1020
+ waitOverrides: parseMessageOverrides(env?.[WAIT_MESSAGES_ENV]),
1021
+ failedOverrides: parseMessageOverrides(env?.[FAILED_MESSAGES_ENV])
974
1022
  };
975
1023
  sessions.set(sessionId, created);
976
1024
  return created;
@@ -996,6 +1044,44 @@ function logSwitch(sessionId, side, result) {
996
1044
  `voice ${side} ${sessionId} ${result.language ?? ""} provider=${provider ?? "unchanged"} model=${model ?? "unchanged"}`
997
1045
  );
998
1046
  }
1047
+ var WAIT_SPEECH_TIMEOUT_MS = 8e3;
1048
+ function startSwitch(sessionId, state) {
1049
+ const pending = state.pendingSwitch;
1050
+ if (!pending) return;
1051
+ clearTimeout(pending.timer);
1052
+ state.pendingSwitch = void 0;
1053
+ state.switching = true;
1054
+ void runSwitch(sessionId, state, pending.language, pending.previous).catch((error) => {
1055
+ state.suppressNextFinal = false;
1056
+ agentLog("warn", `language-switch ${sessionId} failed: ${String(error)}`);
1057
+ }).finally(() => {
1058
+ state.switching = false;
1059
+ });
1060
+ }
1061
+ async function runSwitch(sessionId, state, language, previous) {
1062
+ const tts = await setVoiceLanguage(sessionId, {
1063
+ scope: "tts",
1064
+ language,
1065
+ voice: language
1066
+ });
1067
+ logSwitch(sessionId, "tts", tts);
1068
+ const stt = await setVoiceLanguage(sessionId, {
1069
+ scope: "stt",
1070
+ language,
1071
+ stt: language
1072
+ });
1073
+ logSwitch(sessionId, "stt", stt);
1074
+ if (!tts.ok) {
1075
+ state.suppressNextFinal = false;
1076
+ speak(
1077
+ sessionId,
1078
+ pickMessage(FAILED_MESSAGES, state.failedOverrides, previous)
1079
+ );
1080
+ return;
1081
+ }
1082
+ state.language = language;
1083
+ speak(sessionId, replyFor(language));
1084
+ }
999
1085
  function prepareLanguageTransition(state) {
1000
1086
  if (state.echoTimer) {
1001
1087
  clearTimeout(state.echoTimer);
@@ -1030,25 +1116,23 @@ defineAgent({
1030
1116
  state.language = language;
1031
1117
  return;
1032
1118
  }
1119
+ if (state.pendingSwitch || state.switching) return;
1033
1120
  prepareLanguageTransition(state);
1034
- const tts = await setVoiceLanguage(sessionId, {
1035
- scope: "tts",
1036
- language,
1037
- voice: language
1038
- });
1039
- logSwitch(sessionId, "tts", tts);
1040
- const stt = await setVoiceLanguage(sessionId, {
1041
- scope: "stt",
1042
- language,
1043
- stt: language
1044
- });
1045
- logSwitch(sessionId, "stt", stt);
1046
- if (!tts.ok) {
1047
- state.suppressNextFinal = false;
1048
- return;
1049
- }
1050
- state.language = language;
1051
- speak(sessionId, replyFor(language));
1121
+ const previous = state.language;
1122
+ speak(sessionId, pickMessage(WAIT_MESSAGES, state.waitOverrides, previous));
1123
+ const timer = setTimeout(() => {
1124
+ agentLog(
1125
+ "warn",
1126
+ `language-switch ${sessionId} no agent_speaking_end within ${WAIT_SPEECH_TIMEOUT_MS}ms; switching anyway`
1127
+ );
1128
+ startSwitch(sessionId, state);
1129
+ }, WAIT_SPEECH_TIMEOUT_MS);
1130
+ state.pendingSwitch = { language, previous, timer };
1131
+ },
1132
+ onSpeechEvent({ sessionId }, event) {
1133
+ if (event.type !== "agent_speaking_end") return;
1134
+ const state = sessions.get(sessionId);
1135
+ if (state?.pendingSwitch) startSwitch(sessionId, state);
1052
1136
  },
1053
1137
  async onVoiceLanguageChanged({ sessionId, language }) {
1054
1138
  const state = stateFor(sessionId);
@@ -1124,6 +1208,16 @@ defineAgent({
1124
1208
  onSessionEnd({ sessionId }) {
1125
1209
  const state = sessions.get(sessionId);
1126
1210
  if (state?.echoTimer) clearTimeout(state.echoTimer);
1211
+ if (state?.pendingSwitch) clearTimeout(state.pendingSwitch.timer);
1127
1212
  sessions.delete(sessionId);
1128
1213
  }
1129
1214
  });
1215
+ export {
1216
+ FAILED_MESSAGES,
1217
+ FAILED_MESSAGES_ENV,
1218
+ WAIT_MESSAGES,
1219
+ WAIT_MESSAGES_ENV,
1220
+ WAIT_SPEECH_TIMEOUT_MS,
1221
+ parseMessageOverrides,
1222
+ pickMessage
1223
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voicethere/agent",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "VoiceThere customer agent SDK — IPC types and runtime helpers for sandboxed child bundles",
5
5
  "type": "module",
6
6
  "exports": {
@@ -9,12 +9,23 @@ Leave the project **spoken-language auto-switch** setting off. When `onUserLangu
9
9
  - `scope: "tts"` switches only the speaking voice
10
10
  - `scope: "stt"` switches only the listening model
11
11
 
12
+ The runner plays nothing for a manual switch, and the target voice and STT pools can take several seconds to warm up. So the agent first speaks a short wait message in the language it is leaving, using the current voice (for example `One moment please, I'm switching to your language.` in English, `Einen Moment bitte, ich wechsle zu Ihrer Sprache.` in German). The runner swaps the voice without draining it, so the agent waits for the `agent_speaking_end` speech event (at most 8 seconds, `WAIT_SPEECH_TIMEOUT_MS`) before it calls `setVoiceLanguage` for TTS and STT and replies in the new language. A second language event during the wait or the switch is ignored. The utterance that revealed the language is not echoed back. If the TTS switch fails (a timeout, for instance), the agent speaks a short fallback in the old language and stays in it.
13
+
14
+ The texts live in the `WAIT_MESSAGES` and `FAILED_MESSAGES` maps in `agent.ts` (en, de, es, fr, it, pt, nl, pl, ru). Override any of them per language with a JSON object in the session env, keyed by ISO 639-1 code:
15
+
16
+ - `LANGUAGE_SWITCH_WAIT_MESSAGES_JSON` — wait message, for example `{"en":"Hold on, switching languages."}`
17
+ - `LANGUAGE_SWITCH_FAILED_MESSAGES_JSON` — fallback after a failed switch
18
+
19
+ Invalid JSON or non-string values are ignored and the built-in text is used. A language without a text falls back to English.
20
+
12
21
  A Sherpa language with no STT id (`it`, `pt`, `nl`, `pl`, `hi`) fails the STT call and still switches TTS.
13
22
 
14
23
  ## Mode B — Runner auto-switch
15
24
 
16
25
  Enable auto-switch in the project voice settings (runner applies STT/TTS when LID detects a new language). When `session_start.env` includes a truthy `SHERPA_LID_AUTO_SWITCH`, this template **does not** call `setVoiceLanguage`. The runner replays the utterance into the new language's STT, so the next final is the recognized text in the new language. The agent remembers the language from `onUserLanguage` / `onVoiceLanguageChanged` and answers each final right away with a short localized prefix, for example `you said: …` in English and `Du hast gesagt: …` in German. Use `onVoiceLanguageChanged` when you need the committed language after the runner applies the change.
17
26
 
27
+ In this mode the runner owns the wait message and the agent speaks no wait or fallback text of its own.
28
+
18
29
  Detection and chat commands are unchanged: `/tts` and `/stt` still call `setVoiceLanguage` for one vendor at a time.
19
30
 
20
31
  Chat commands change one vendor while the session stays connected. API keys are project secrets on the running deploy, not arguments:
@@ -4,7 +4,17 @@
4
4
  * Two deployment modes (see README):
5
5
  *
6
6
  * (A) Project auto-switch off — this agent calls `setVoiceLanguage` from
7
- * `onUserLanguage` when LID detects a new language.
7
+ * `onUserLanguage` when LID detects a new language. The runner plays
8
+ * nothing for manual switches and the target pools may be cold for
9
+ * several seconds, so the agent first speaks a wait message in the
10
+ * language being left (current voice), waits for `agent_speaking_end`
11
+ * (at most WAIT_SPEECH_TIMEOUT_MS) so the runner does not swap the voice
12
+ * mid-sentence, then switches TTS and STT, then replies in the new
13
+ * language. If the switch fails it speaks a short
14
+ * fallback in the old language and stays. Texts live in WAIT_MESSAGES /
15
+ * FAILED_MESSAGES and can be overridden per language with the session env
16
+ * vars LANGUAGE_SWITCH_WAIT_MESSAGES_JSON and
17
+ * LANGUAGE_SWITCH_FAILED_MESSAGES_JSON (JSON object, ISO 639-1 -> text).
8
18
  * (B) Project enables runner auto-switch — STT/TTS are runner-owned. The
9
19
  * runner replays the utterance into the new language's STT, so the next
10
20
  * final is the correctly recognized text. The agent never calls
@@ -47,6 +57,70 @@ const ECHO_PREFIXES: Record<string, string> = {
47
57
  ru: "Вы сказали:",
48
58
  };
49
59
 
60
+ /** Spoken in the language being left, before a manual switch starts. */
61
+ export const WAIT_MESSAGES: Record<string, string> = {
62
+ en: "One moment please, I'm switching to your language.",
63
+ de: "Einen Moment bitte, ich wechsle zu Ihrer Sprache.",
64
+ es: "Un momento, por favor, estoy cambiando a su idioma.",
65
+ fr: "Un instant, je passe dans votre langue.",
66
+ it: "Un momento, per favore, sto passando alla tua lingua.",
67
+ pt: "Um momento, por favor, estou mudando para o seu idioma.",
68
+ nl: "Een moment alstublieft, ik schakel over naar uw taal.",
69
+ pl: "Chwileczkę, przełączam się na Twój język.",
70
+ ru: "Одну минуту, я перехожу на ваш язык.",
71
+ };
72
+
73
+ /** Spoken in the old language when the switch fails and the agent stays. */
74
+ export const FAILED_MESSAGES: Record<string, string> = {
75
+ en: "Sorry, I couldn't switch languages. I'll keep going in English.",
76
+ de: "Entschuldigung, der Sprachwechsel hat nicht geklappt. Ich bleibe bei Deutsch.",
77
+ es: "Lo siento, no pude cambiar de idioma. Sigo en español.",
78
+ fr: "Désolé, je n'ai pas pu changer de langue. Je continue en français.",
79
+ it: "Mi dispiace, non sono riuscito a cambiare lingua. Continuo in italiano.",
80
+ pt: "Desculpe, não consegui mudar de idioma. Continuo em português.",
81
+ nl: "Sorry, het wisselen van taal is niet gelukt. Ik blijf Nederlands spreken.",
82
+ pl: "Przepraszam, nie udało się zmienić języka. Zostaję przy polskim.",
83
+ ru: "Извините, не удалось сменить язык. Продолжаю по-русски.",
84
+ };
85
+
86
+ export const WAIT_MESSAGES_ENV = "LANGUAGE_SWITCH_WAIT_MESSAGES_JSON";
87
+ export const FAILED_MESSAGES_ENV = "LANGUAGE_SWITCH_FAILED_MESSAGES_JSON";
88
+
89
+ /**
90
+ * Parse a per-language override from a session env var. Accepts a JSON object
91
+ * of language code -> non-empty string; anything else yields no overrides.
92
+ */
93
+ export function parseMessageOverrides(
94
+ raw: string | undefined,
95
+ ): Record<string, string> {
96
+ if (!raw) return {};
97
+ let parsed: unknown;
98
+ try {
99
+ parsed = JSON.parse(raw);
100
+ } catch {
101
+ return {};
102
+ }
103
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
104
+ const out: Record<string, string> = {};
105
+ for (const [key, value] of Object.entries(parsed)) {
106
+ if (typeof value === "string" && value.trim()) {
107
+ out[key.trim().toLowerCase()] = value.trim();
108
+ }
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /** Message for `language`: override, then default, then English default. */
114
+ export function pickMessage(
115
+ defaults: Record<string, string>,
116
+ overrides: Record<string, string>,
117
+ language: string,
118
+ ): string {
119
+ return (
120
+ overrides[language] ?? defaults[language] ?? overrides.en ?? defaults.en!
121
+ );
122
+ }
123
+
50
124
  const REPLIES: Record<string, string> = {
51
125
  de: "Guten Tag. Ich antworte jetzt auf Deutsch.",
52
126
  es: "Hola. Ahora respondo en español.",
@@ -63,6 +137,16 @@ type SessionLanguage = {
63
137
  runnerAutoSwitch: boolean;
64
138
  echoTimer: ReturnType<typeof setTimeout> | undefined;
65
139
  suppressNextFinal: boolean;
140
+ pendingSwitch:
141
+ | {
142
+ language: string;
143
+ previous: string;
144
+ timer: ReturnType<typeof setTimeout>;
145
+ }
146
+ | undefined;
147
+ switching: boolean;
148
+ waitOverrides: Record<string, string>;
149
+ failedOverrides: Record<string, string>;
66
150
  };
67
151
 
68
152
  const sessions = new Map<string, SessionLanguage>();
@@ -78,6 +162,10 @@ function stateFor(
78
162
  runnerAutoSwitch: env ? isRunnerLidAutoSwitchEnabled(env) : false,
79
163
  echoTimer: undefined,
80
164
  suppressNextFinal: false,
165
+ pendingSwitch: undefined,
166
+ switching: false,
167
+ waitOverrides: parseMessageOverrides(env?.[WAIT_MESSAGES_ENV]),
168
+ failedOverrides: parseMessageOverrides(env?.[FAILED_MESSAGES_ENV]),
81
169
  };
82
170
  sessions.set(sessionId, created);
83
171
  return created;
@@ -111,6 +199,61 @@ function logSwitch(
111
199
  );
112
200
  }
113
201
 
202
+ /** Upper bound for waiting on the wait message before switching anyway. */
203
+ export const WAIT_SPEECH_TIMEOUT_MS = 8000;
204
+
205
+ /** Consume the pending switch (once) and run it without blocking handlers. */
206
+ function startSwitch(sessionId: string, state: SessionLanguage): void {
207
+ const pending = state.pendingSwitch;
208
+ if (!pending) return;
209
+ clearTimeout(pending.timer);
210
+ state.pendingSwitch = undefined;
211
+ state.switching = true;
212
+ void runSwitch(sessionId, state, pending.language, pending.previous)
213
+ .catch((error: unknown) => {
214
+ state.suppressNextFinal = false;
215
+ agentLog("warn", `language-switch ${sessionId} failed: ${String(error)}`);
216
+ })
217
+ .finally(() => {
218
+ state.switching = false;
219
+ });
220
+ }
221
+
222
+ async function runSwitch(
223
+ sessionId: string,
224
+ state: SessionLanguage,
225
+ language: string,
226
+ previous: string,
227
+ ): Promise<void> {
228
+ const tts = await setVoiceLanguage(sessionId, {
229
+ scope: "tts",
230
+ language,
231
+ voice: language,
232
+ });
233
+ logSwitch(sessionId, "tts", tts);
234
+
235
+ const stt = await setVoiceLanguage(sessionId, {
236
+ scope: "stt",
237
+ language,
238
+ stt: language,
239
+ });
240
+ logSwitch(sessionId, "stt", stt);
241
+
242
+ if (!tts.ok) {
243
+ state.suppressNextFinal = false;
244
+ // Switch failed (for example a timeout): the voice is unchanged, so
245
+ // apologise in the old language and stay.
246
+ speak(
247
+ sessionId,
248
+ pickMessage(FAILED_MESSAGES, state.failedOverrides, previous),
249
+ );
250
+ return;
251
+ }
252
+
253
+ state.language = language;
254
+ speak(sessionId, replyFor(language));
255
+ }
256
+
114
257
  function prepareLanguageTransition(state: SessionLanguage): void {
115
258
  if (state.echoTimer) {
116
259
  clearTimeout(state.echoTimer);
@@ -152,29 +295,35 @@ defineAgent({
152
295
  return;
153
296
  }
154
297
 
155
- prepareLanguageTransition(state);
298
+ // A switch is already waiting for the wait message or in flight.
299
+ if (state.pendingSwitch || state.switching) return;
156
300
 
157
- const tts = await setVoiceLanguage(sessionId, {
158
- scope: "tts",
159
- language,
160
- voice: language,
161
- });
162
- logSwitch(sessionId, "tts", tts);
301
+ prepareLanguageTransition(state);
163
302
 
164
- const stt = await setVoiceLanguage(sessionId, {
165
- scope: "stt",
166
- language,
167
- stt: language,
168
- });
169
- logSwitch(sessionId, "stt", stt);
303
+ // The runner plays nothing for manual switches and the target pools may be
304
+ // cold, so tell the user first, in the language being left.
305
+ const previous = state.language;
306
+ speak(sessionId, pickMessage(WAIT_MESSAGES, state.waitOverrides, previous));
170
307
 
171
- if (!tts.ok) {
172
- state.suppressNextFinal = false;
173
- return;
174
- }
308
+ // `speak` is fire-and-forget and the runner swaps the TTS without draining
309
+ // it, so the switch must wait until the wait message has been spoken
310
+ // (`agent_speaking_end`, delivered to onSpeechEvent). Inbound handlers of
311
+ // one session run strictly in order, so this handler must return instead
312
+ // of awaiting that event. The bounded timer covers a missing event.
313
+ const timer = setTimeout(() => {
314
+ agentLog(
315
+ "warn",
316
+ `language-switch ${sessionId} no agent_speaking_end within ${WAIT_SPEECH_TIMEOUT_MS}ms; switching anyway`,
317
+ );
318
+ startSwitch(sessionId, state);
319
+ }, WAIT_SPEECH_TIMEOUT_MS);
320
+ state.pendingSwitch = { language, previous, timer };
321
+ },
175
322
 
176
- state.language = language;
177
- speak(sessionId, replyFor(language));
323
+ onSpeechEvent({ sessionId }, event) {
324
+ if (event.type !== "agent_speaking_end") return;
325
+ const state = sessions.get(sessionId);
326
+ if (state?.pendingSwitch) startSwitch(sessionId, state);
178
327
  },
179
328
 
180
329
  async onVoiceLanguageChanged({ sessionId, language }) {
@@ -256,6 +405,7 @@ defineAgent({
256
405
  onSessionEnd({ sessionId }) {
257
406
  const state = sessions.get(sessionId);
258
407
  if (state?.echoTimer) clearTimeout(state.echoTimer);
408
+ if (state?.pendingSwitch) clearTimeout(state.pendingSwitch.timer);
259
409
  sessions.delete(sessionId);
260
410
  },
261
411
  });