@voicethere/agent 0.9.1 → 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.
@@ -941,6 +941,61 @@ function parseChatText(message) {
941
941
  // templates/language-switch/agent.ts
942
942
  var ECHO_PREFIX = "you said:";
943
943
  var ECHO_WAIT_MS = 4e3;
944
+ var ECHO_PREFIXES = {
945
+ en: "you said:",
946
+ de: "Du hast gesagt:",
947
+ es: "Dijiste:",
948
+ fr: "Tu as dit :",
949
+ it: "Hai detto:",
950
+ pt: "Voc\xEA disse:",
951
+ nl: "Je zei:",
952
+ pl: "Powiedzia\u0142e\u015B:",
953
+ ru: "\u0412\u044B \u0441\u043A\u0430\u0437\u0430\u043B\u0438:"
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
+ }
944
999
  var REPLIES = {
945
1000
  de: "Guten Tag. Ich antworte jetzt auf Deutsch.",
946
1001
  es: "Hola. Ahora respondo en espa\xF1ol.",
@@ -959,7 +1014,11 @@ function stateFor(sessionId, env) {
959
1014
  language: "en",
960
1015
  runnerAutoSwitch: env ? isRunnerLidAutoSwitchEnabled(env) : false,
961
1016
  echoTimer: void 0,
962
- 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])
963
1022
  };
964
1023
  sessions.set(sessionId, created);
965
1024
  return created;
@@ -967,6 +1026,9 @@ function stateFor(sessionId, env) {
967
1026
  function replyFor(language) {
968
1027
  return REPLIES[language] ?? `Continuing in ${language}.`;
969
1028
  }
1029
+ function echoPrefixFor(language) {
1030
+ return ECHO_PREFIXES[language] ?? ECHO_PREFIXES.en;
1031
+ }
970
1032
  function logSwitch(sessionId, side, result) {
971
1033
  if (!result.ok) {
972
1034
  agentLog(
@@ -982,6 +1044,44 @@ function logSwitch(sessionId, side, result) {
982
1044
  `voice ${side} ${sessionId} ${result.language ?? ""} provider=${provider ?? "unchanged"} model=${model ?? "unchanged"}`
983
1045
  );
984
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
+ }
985
1085
  function prepareLanguageTransition(state) {
986
1086
  if (state.echoTimer) {
987
1087
  clearTimeout(state.echoTimer);
@@ -1008,34 +1108,31 @@ defineAgent({
1008
1108
  async onUserLanguage({ sessionId, language }) {
1009
1109
  const state = stateFor(sessionId);
1010
1110
  if (!language || language === state.language) return;
1011
- prepareLanguageTransition(state);
1012
1111
  if (state.runnerAutoSwitch) {
1013
1112
  agentLog(
1014
1113
  "info",
1015
- `LID detected ${language}; runner auto-switch applies STT/TTS \u2014 agent updates prompts only`
1114
+ `LID detected ${language}; runner auto-switch applies STT/TTS \u2014 agent only remembers the language`
1016
1115
  );
1017
1116
  state.language = language;
1018
- speak(sessionId, replyFor(language));
1019
1117
  return;
1020
1118
  }
1021
- const tts = await setVoiceLanguage(sessionId, {
1022
- scope: "tts",
1023
- language,
1024
- voice: language
1025
- });
1026
- logSwitch(sessionId, "tts", tts);
1027
- const stt = await setVoiceLanguage(sessionId, {
1028
- scope: "stt",
1029
- language,
1030
- stt: language
1031
- });
1032
- logSwitch(sessionId, "stt", stt);
1033
- if (!tts.ok) {
1034
- state.suppressNextFinal = false;
1035
- return;
1036
- }
1037
- state.language = language;
1038
- speak(sessionId, replyFor(language));
1119
+ if (state.pendingSwitch || state.switching) return;
1120
+ prepareLanguageTransition(state);
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);
1039
1136
  },
1040
1137
  async onVoiceLanguageChanged({ sessionId, language }) {
1041
1138
  const state = stateFor(sessionId);
@@ -1094,6 +1191,10 @@ defineAgent({
1094
1191
  },
1095
1192
  onUserSpeechFinal({ sessionId, text }) {
1096
1193
  const state = stateFor(sessionId);
1194
+ if (state.runnerAutoSwitch) {
1195
+ speak(sessionId, `${echoPrefixFor(state.language)} ${text}`.trim());
1196
+ return;
1197
+ }
1097
1198
  if (state.suppressNextFinal) {
1098
1199
  state.suppressNextFinal = false;
1099
1200
  return;
@@ -1107,6 +1208,16 @@ defineAgent({
1107
1208
  onSessionEnd({ sessionId }) {
1108
1209
  const state = sessions.get(sessionId);
1109
1210
  if (state?.echoTimer) clearTimeout(state.echoTimer);
1211
+ if (state?.pendingSwitch) clearTimeout(state.pendingSwitch.timer);
1110
1212
  sessions.delete(sessionId);
1111
1213
  }
1112
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
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/templates/registry.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,YAAY,GAAG,SAAS,GAAG,KAAK,CAAC;AAE7C,MAAM,WAAW,uBAAuB;IACtC,EAAE,EAAE,MAAM,CAAC;IACX,mFAAmF;IACnF,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,YAAY,CAAC;IACnB,mFAAmF;IACnF,YAAY,EAAE,OAAO,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,yEAAyE;IACzE,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,oFAAoF;IACpF,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC1C;AAED,eAAO,MAAM,eAAe,EAAE,SAAS,uBAAuB,EA8LpD,CAAC;AAEX,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC;AAMrE,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,MAAM,GAAG,EAAE,IAAI,eAAe,CAEnE;AAED,wBAAgB,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG,uBAAuB,CAMnE;AAED,wBAAgB,yBAAyB,IAAI,uBAAuB,EAAE,CAErE"}
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/templates/registry.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,YAAY,GAAG,SAAS,GAAG,KAAK,CAAC;AAE7C,MAAM,WAAW,uBAAuB;IACtC,EAAE,EAAE,MAAM,CAAC;IACX,mFAAmF;IACnF,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,YAAY,CAAC;IACnB,mFAAmF;IACnF,YAAY,EAAE,OAAO,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,yEAAyE;IACzE,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,oFAAoF;IACpF,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC1C;AAED,eAAO,MAAM,eAAe,EAAE,SAAS,uBAAuB,EAiMpD,CAAC;AAEX,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC;AAMrE,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,MAAM,GAAG,EAAE,IAAI,eAAe,CAEnE;AAED,wBAAgB,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG,uBAAuB,CAMnE;AAED,wBAAgB,yBAAyB,IAAI,uBAAuB,EAAE,CAErE"}
@@ -20,7 +20,7 @@ export const AGENT_TEMPLATES = [
20
20
  entry: "language-switch/agent.ts",
21
21
  kind: "product",
22
22
  seedOnCreate: true,
23
- description: "Switches STT and TTS independently from onUserLanguage, and can change one vendor mid-conversation. Detection does not change either side until this agent calls setVoiceLanguage.",
23
+ description: "Switches STT and TTS independently from onUserLanguage, and can change one vendor mid-conversation. With project auto-switch on, the runner switches and replays the utterance, and the agent answers the recognized text in the new language. With auto-switch off, detection does not change either side until this agent calls setVoiceLanguage.",
24
24
  sourceFiles: ["language-switch/agent.ts"],
25
25
  },
26
26
  {
@@ -45,7 +45,10 @@ export const AGENT_TEMPLATES = [
45
45
  kind: "product",
46
46
  seedOnCreate: true,
47
47
  description: "Single-agent binary world sync — onDataChannelBinary + sendBinaryToClient ArrayBuffer poses, no Redis.",
48
- sourceFiles: ["world-sync-binary/agent.ts", "world-sync-binary/protocol.ts"],
48
+ sourceFiles: [
49
+ "world-sync-binary/agent.ts",
50
+ "world-sync-binary/protocol.ts",
51
+ ],
49
52
  },
50
53
  {
51
54
  id: "game-sync",
@@ -1 +1 @@
1
- {"version":3,"file":"registry.js","sourceRoot":"","sources":["../../src/templates/registry.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"registry.js","sourceRoot":"","sources":["../../src/templates/registry.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voicethere/agent",
3
- "version": "0.9.1",
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": {
@@ -64,7 +64,7 @@ Each folder has its own README. Summary:
64
64
  | `echo` | `echo/` | Voice + chat echo |
65
65
  | `echo-dc` | `echo-dc/` | Data-channel echo, no TTS |
66
66
  | `voice-starter` | `voice-starter/` | Every speech event |
67
- | `language-switch` | `language-switch/` | Manual `setVoiceLanguage` or runner auto-switch + prompt handlers |
67
+ | `language-switch` | `language-switch/` | Manual `setVoiceLanguage`, or runner auto-switch with a localized echo of the recognized text |
68
68
  | `world-sync` | `world-sync/` | JSON pose broadcast |
69
69
  | `world-sync-binary` | `world-sync-binary/` | Binary pose `ArrayBuffer` |
70
70
  | `game-sync` | `game-sync/` | Authoritative sim, Redis + binary snapshots |
@@ -9,11 +9,22 @@ 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
- 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` from `onUserLanguage` — it logs that the runner owns the switch and updates prompts only. Use `onVoiceLanguageChanged` when you need the committed language after the runner applies the change.
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.
26
+
27
+ In this mode the runner owns the wait message and the agent speaks no wait or fallback text of its own.
17
28
 
18
29
  Detection and chat commands are unchanged: `/tts` and `/stt` still call `setVoiceLanguage` for one vendor at a time.
19
30
 
@@ -24,7 +35,7 @@ Chat commands change one vendor while the session stays connected. API keys are
24
35
  - `/tts elevenlabs` — ElevenLabs TTS, current STT stays
25
36
  - `/stt deepgram de` — Deepgram STT, current voice stays
26
37
 
27
- English finals are echoed as `you said: …` after a short wait, so a language event for the same utterance can cancel the echo. The revealing utterance is not spoken back as an English transcript.
38
+ In manual mode, English finals are echoed as `you said: …` after a short wait, so a language event for the same utterance can cancel the echo. The revealing utterance is not spoken back as an English transcript.
28
39
 
29
40
  `voice` and `stt` are Sherpa catalog ids (`de`, `en-lessac`, `en-small`), listed in the spoken-language docs. Vendor ids are `local-sherpa`, `openai`, `deepgram`, `assemblyai`, `google`, `elevenlabs`, and `cartesia`.
30
41
 
@@ -4,9 +4,22 @@
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.
8
- * (B) Project enables runner auto-switch — STT/TTS are runner-owned; use
9
- * `onUserLanguage` / `onVoiceLanguageChanged` for prompts only.
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).
18
+ * (B) Project enables runner auto-switch — STT/TTS are runner-owned. The
19
+ * runner replays the utterance into the new language's STT, so the next
20
+ * final is the correctly recognized text. The agent never calls
21
+ * `setVoiceLanguage`; it remembers the language and answers each final
22
+ * right away with a short localized prefix ("Du hast gesagt: …").
10
23
  *
11
24
  * Chat commands change a single vendor mid-conversation:
12
25
  *
@@ -31,6 +44,83 @@ import {
31
44
  const ECHO_PREFIX = "you said:";
32
45
  const ECHO_WAIT_MS = 4000;
33
46
 
47
+ /** Echo prefix per language for runner auto-switch mode. */
48
+ const ECHO_PREFIXES: Record<string, string> = {
49
+ en: "you said:",
50
+ de: "Du hast gesagt:",
51
+ es: "Dijiste:",
52
+ fr: "Tu as dit :",
53
+ it: "Hai detto:",
54
+ pt: "Você disse:",
55
+ nl: "Je zei:",
56
+ pl: "Powiedziałeś:",
57
+ ru: "Вы сказали:",
58
+ };
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
+
34
124
  const REPLIES: Record<string, string> = {
35
125
  de: "Guten Tag. Ich antworte jetzt auf Deutsch.",
36
126
  es: "Hola. Ahora respondo en español.",
@@ -47,6 +137,16 @@ type SessionLanguage = {
47
137
  runnerAutoSwitch: boolean;
48
138
  echoTimer: ReturnType<typeof setTimeout> | undefined;
49
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>;
50
150
  };
51
151
 
52
152
  const sessions = new Map<string, SessionLanguage>();
@@ -62,6 +162,10 @@ function stateFor(
62
162
  runnerAutoSwitch: env ? isRunnerLidAutoSwitchEnabled(env) : false,
63
163
  echoTimer: undefined,
64
164
  suppressNextFinal: false,
165
+ pendingSwitch: undefined,
166
+ switching: false,
167
+ waitOverrides: parseMessageOverrides(env?.[WAIT_MESSAGES_ENV]),
168
+ failedOverrides: parseMessageOverrides(env?.[FAILED_MESSAGES_ENV]),
65
169
  };
66
170
  sessions.set(sessionId, created);
67
171
  return created;
@@ -71,6 +175,10 @@ function replyFor(language: string): string {
71
175
  return REPLIES[language] ?? `Continuing in ${language}.`;
72
176
  }
73
177
 
178
+ function echoPrefixFor(language: string): string {
179
+ return ECHO_PREFIXES[language] ?? ECHO_PREFIXES.en!;
180
+ }
181
+
74
182
  function logSwitch(
75
183
  sessionId: string,
76
184
  side: string,
@@ -91,6 +199,61 @@ function logSwitch(
91
199
  );
92
200
  }
93
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
+
94
257
  function prepareLanguageTransition(state: SessionLanguage): void {
95
258
  if (state.echoTimer) {
96
259
  clearTimeout(state.echoTimer);
@@ -120,39 +283,47 @@ defineAgent({
120
283
  const state = stateFor(sessionId);
121
284
  if (!language || language === state.language) return;
122
285
 
123
- prepareLanguageTransition(state);
124
-
125
286
  if (state.runnerAutoSwitch) {
287
+ // The runner switches STT/TTS and replays the utterance into the new
288
+ // STT; the next final is the real utterance, so nothing is suppressed
289
+ // and no fixed sentence is spoken.
126
290
  agentLog(
127
291
  "info",
128
- `LID detected ${language}; runner auto-switch applies STT/TTS — agent updates prompts only`,
292
+ `LID detected ${language}; runner auto-switch applies STT/TTS — agent only remembers the language`,
129
293
  );
130
294
  state.language = language;
131
- speak(sessionId, replyFor(language));
132
295
  return;
133
296
  }
134
297
 
135
- const tts = await setVoiceLanguage(sessionId, {
136
- scope: "tts",
137
- language,
138
- voice: language,
139
- });
140
- logSwitch(sessionId, "tts", tts);
298
+ // A switch is already waiting for the wait message or in flight.
299
+ if (state.pendingSwitch || state.switching) return;
141
300
 
142
- const stt = await setVoiceLanguage(sessionId, {
143
- scope: "stt",
144
- language,
145
- stt: language,
146
- });
147
- logSwitch(sessionId, "stt", stt);
301
+ prepareLanguageTransition(state);
148
302
 
149
- if (!tts.ok) {
150
- state.suppressNextFinal = false;
151
- return;
152
- }
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));
153
307
 
154
- state.language = language;
155
- speak(sessionId, replyFor(language));
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
+ },
322
+
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);
156
327
  },
157
328
 
158
329
  async onVoiceLanguageChanged({ sessionId, language }) {
@@ -214,6 +385,12 @@ defineAgent({
214
385
 
215
386
  onUserSpeechFinal({ sessionId, text }) {
216
387
  const state = stateFor(sessionId);
388
+ if (state.runnerAutoSwitch) {
389
+ // Reply immediately: the runner already holds the final until LID has
390
+ // decided, so no extra wait is needed to avoid a wrong-language echo.
391
+ speak(sessionId, `${echoPrefixFor(state.language)} ${text}`.trim());
392
+ return;
393
+ }
217
394
  if (state.suppressNextFinal) {
218
395
  state.suppressNextFinal = false;
219
396
  return;
@@ -228,6 +405,7 @@ defineAgent({
228
405
  onSessionEnd({ sessionId }) {
229
406
  const state = sessions.get(sessionId);
230
407
  if (state?.echoTimer) clearTimeout(state.echoTimer);
408
+ if (state?.pendingSwitch) clearTimeout(state.pendingSwitch.timer);
231
409
  sessions.delete(sessionId);
232
410
  },
233
411
  });