diff --git a/CHANGELOG.md b/CHANGELOG.md index afafc77..60170e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). Versioning foll ## [Unreleased] +### Added + +- **`projects voice-advanced`** — language switch wait settings: `languageId.autoSwitch.waitAudio` (`buffer_replay` or `first_utterance`), `languageId.autoSwitch.waitMessage.mode` (`end_of_utterance`, `immediate`, `off`), `languageId.autoSwitch.waitMessage.skipWhenReady`, and `languageId.autoSwitch.waitMessage.texts` (JSON map of language code to text, up to 300 characters each). +- **`projects voice-advanced`** — `languageId.timing` (`early`, `end_of_utterance`, `continuous`; default `early`): when the spoken language is identified. `early` runs once as soon as `minSpeechMs` of speech is buffered, while the caller is still talking. +- **`projects voice-advanced`** — `voice.profilesByLanguage` and the wait texts are checked for JSON shape before the request is sent. + +### Changed + +- **`projects voice-advanced`** — `languageId.minSpeechMs` now defaults to 1500 (range still 1000–5000), matching the dashboard. +- **`projects voice-advanced`** — `languageId.autoSwitch.finalHoldMs` now defaults to 5000 and accepts up to 10000, matching the dashboard (it was 500 and 1500 in the CLI). + +### Removed + +- **`projects voice-advanced`** — `voice.expectedLanguages`. Only the boot STT/TTS pools start warm now. Pools for other languages start when a switch needs them, and the per-language models come from `voice.profilesByLanguage`. + ## [0.14.9] - 2026-09-29 ### Added diff --git a/README.md b/README.md index b7222de..b15d814 100644 --- a/README.md +++ b/README.md @@ -390,6 +390,32 @@ voicethere projects session-settings set error_message "Sorry, something went wr Boolean values for `set`: `true` / `false` / `1` / `0` / `yes` / `no`. +## Language switch settings + +Spoken-language auto-switch and its wait behavior are advanced voice settings. They apply on the next **`voicethere deploy --wait`**. When the caller switches to a language whose models are not running yet, the runner starts them on demand and can play a short wait message in the language being left. + +```bash +voicethere projects voice-advanced list +voicethere projects voice-advanced set languageId.autoSwitch.enabled true +voicethere projects voice-advanced set languageId.timing end_of_utterance +voicethere projects voice-advanced set languageId.autoSwitch.waitAudio first_utterance +voicethere projects voice-advanced set languageId.autoSwitch.waitMessage.mode immediate +voicethere projects voice-advanced set languageId.autoSwitch.waitMessage.skipWhenReady false +voicethere projects voice-advanced set languageId.autoSwitch.waitMessage.texts '{"en":"One moment, switching to your language."}' +voicethere projects voice-advanced set voice.profilesByLanguage '{"de":{"stt":"de","tts":"de-thorsten-high"}}' +``` + +| Key | Values | Default | +| ------------------------------------------------- | ---------------------------------------------- | ------------------ | +| `languageId.timing` | `early`, `end_of_utterance`, `continuous` | `early` | +| `languageId.autoSwitch.waitAudio` | `buffer_replay`, `first_utterance` | `buffer_replay` | +| `languageId.autoSwitch.waitMessage.mode` | `end_of_utterance`, `immediate`, `off` | `end_of_utterance` | +| `languageId.autoSwitch.waitMessage.skipWhenReady` | bool | `true` | +| `languageId.autoSwitch.waitMessage.texts` | JSON map of language code to text, 300 chars | built-in texts | +| `voice.profilesByLanguage` | JSON map of language code to `stt` / `tts` ids | `{}` | + +`voice.expectedLanguages` no longer exists. Only the boot STT/TTS pools start warm. + ## Billing settings Per-project metered overage, conversation/agent-log overage toggles, and optional spend cap. Matches dashboard **Billing** / subscriptions permissions. diff --git a/src/commands/projects/voice-advanced/commands.test.ts b/src/commands/projects/voice-advanced/commands.test.ts index b5eacae..5519c32 100644 --- a/src/commands/projects/voice-advanced/commands.test.ts +++ b/src/commands/projects/voice-advanced/commands.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { VOICE_ADVANCED_SETTING_KEYS } from "./defs.js"; +import { + VOICE_ADVANCED_SETTING_DEFS, + VOICE_ADVANCED_SETTING_KEYS, +} from "./defs.js"; import { runProjectsVoiceAdvancedList } from "./list.js"; import { runProjectsVoiceAdvancedReset, @@ -48,9 +51,22 @@ describe("projects voice-advanced commands", () => { expect(VOICE_ADVANCED_SETTING_KEYS).toContain("noiseSuppression.enabled"); expect(VOICE_ADVANCED_SETTING_KEYS).toContain("languageId.enabled"); expect(VOICE_ADVANCED_SETTING_KEYS).toContain("languageId.minSpeechMs"); - expect(VOICE_ADVANCED_SETTING_KEYS).toContain("languageId.autoSwitch.enabled"); + expect(VOICE_ADVANCED_SETTING_KEYS).toContain( + "languageId.autoSwitch.enabled", + ); expect(VOICE_ADVANCED_SETTING_KEYS).toContain("voice.profilesByLanguage"); - expect(VOICE_ADVANCED_SETTING_KEYS).toHaveLength(33); + expect(VOICE_ADVANCED_SETTING_KEYS).toHaveLength(37); + expect(VOICE_ADVANCED_SETTING_KEYS).not.toContain( + "voice.expectedLanguages", + ); + for (const key of [ + "languageId.autoSwitch.waitAudio", + "languageId.autoSwitch.waitMessage.mode", + "languageId.autoSwitch.waitMessage.skipWhenReady", + "languageId.autoSwitch.waitMessage.texts", + ]) { + expect(VOICE_ADVANCED_SETTING_KEYS).toContain(key); + } }); it("lists resolved advanced settings", async () => { @@ -141,6 +157,202 @@ describe("projects voice-advanced commands", () => { ).rejects.toThrow(/between 0\.2 and 2/); }); + describe("language switch wait settings", () => { + const set = (name: string, value: string) => + runProjectsVoiceAdvancedSet({ name, value }); + + it("rejects the removed voice.expectedLanguages key", async () => { + await expect(set("voice.expectedLanguages", "en,de")).rejects.toThrow( + /Unknown voice advanced setting voice\.expectedLanguages/, + ); + }); + + it("accepts each waitAudio value and rejects others", async () => { + setProjectVoiceAdvancedSetting.mockResolvedValue({ + project_id: "proj-1", + settings: {}, + }); + for (const value of ["buffer_replay", "first_utterance"]) { + await set("languageId.autoSwitch.waitAudio", value); + expect(setProjectVoiceAdvancedSetting).toHaveBeenLastCalledWith( + "proj-1", + "languageId.autoSwitch.waitAudio", + value, + ); + } + await expect( + set("languageId.autoSwitch.waitAudio", "all"), + ).rejects.toThrow(/must be one of: buffer_replay, first_utterance/); + }); + + it("accepts each waitMessage.mode value and rejects others", async () => { + setProjectVoiceAdvancedSetting.mockResolvedValue({ + project_id: "proj-1", + settings: {}, + }); + for (const value of ["end_of_utterance", "immediate", "off"]) { + await set("languageId.autoSwitch.waitMessage.mode", value); + expect(setProjectVoiceAdvancedSetting).toHaveBeenLastCalledWith( + "proj-1", + "languageId.autoSwitch.waitMessage.mode", + value, + ); + } + await expect( + set("languageId.autoSwitch.waitMessage.mode", "later"), + ).rejects.toThrow(/must be one of: end_of_utterance, immediate, off/); + }); + + it("parses waitMessage.skipWhenReady as a boolean", async () => { + setProjectVoiceAdvancedSetting.mockResolvedValue({ + project_id: "proj-1", + settings: {}, + }); + await set("languageId.autoSwitch.waitMessage.skipWhenReady", "no"); + expect(setProjectVoiceAdvancedSetting).toHaveBeenLastCalledWith( + "proj-1", + "languageId.autoSwitch.waitMessage.skipWhenReady", + false, + ); + await expect( + set("languageId.autoSwitch.waitMessage.skipWhenReady", "maybe"), + ).rejects.toThrow(/Invalid boolean/); + }); + + it("validates waitMessage.texts JSON", async () => { + setProjectVoiceAdvancedSetting.mockResolvedValue({ + project_id: "proj-1", + settings: {}, + }); + const key = "languageId.autoSwitch.waitMessage.texts"; + await set(key, '{"en":"One moment, switching."}'); + expect(setProjectVoiceAdvancedSetting).toHaveBeenLastCalledWith( + "proj-1", + key, + '{"en":"One moment, switching."}', + ); + await expect(set(key, "nope")).rejects.toThrow(/valid JSON object/); + await expect(set(key, "[]")).rejects.toThrow( + /JSON object keyed by language code/, + ); + await expect(set(key, '{"english":"x"}')).rejects.toThrow( + /invalid language key/, + ); + await expect(set(key, '{"en":3}')).rejects.toThrow(/non-empty string/); + await expect(set(key, '{"en":" "}')).rejects.toThrow(/non-empty string/); + await expect( + set(key, JSON.stringify({ en: "a".repeat(301) })), + ).rejects.toThrow(/at most 300 characters/); + }); + + it("validates voice.profilesByLanguage shape", async () => { + setProjectVoiceAdvancedSetting.mockResolvedValue({ + project_id: "proj-1", + settings: {}, + }); + const key = "voice.profilesByLanguage"; + await set(key, '{"de":{"stt":"de","tts":"de-thorsten-high"}}'); + expect(setProjectVoiceAdvancedSetting).toHaveBeenLastCalledWith( + "proj-1", + key, + '{"de":{"stt":"de","tts":"de-thorsten-high"}}', + ); + await expect(set(key, "{")).rejects.toThrow(/valid JSON object/); + await expect(set(key, '{"german":{"stt":"de"}}')).rejects.toThrow( + /invalid language key/, + ); + await expect(set(key, '{"de":"de"}')).rejects.toThrow( + /must be an object/, + ); + await expect(set(key, '{"de":{"stt":5}}')).rejects.toThrow( + /must be a string/, + ); + await expect(set(key, '{"de":{}}')).rejects.toThrow(/is empty/); + }); + + it("sets languageId.timing to each allowed value and rejects others", async () => { + expect(VOICE_ADVANCED_SETTING_KEYS).toContain("languageId.timing"); + expect(VOICE_ADVANCED_SETTING_KEYS).not.toContain( + "languageId.continuous", + ); + setProjectVoiceAdvancedSetting.mockResolvedValue({ + project_id: "proj-1", + settings: {}, + }); + for (const value of ["early", "end_of_utterance", "continuous"]) { + await set("languageId.timing", value); + expect(setProjectVoiceAdvancedSetting).toHaveBeenLastCalledWith( + "proj-1", + "languageId.timing", + value, + ); + } + await expect(set("languageId.timing", "true")).rejects.toThrow( + /must be one of: early, end_of_utterance, continuous/, + ); + }); + + it("defaults minSpeechMs to 1500 within 1000–5000", async () => { + expect( + VOICE_ADVANCED_SETTING_DEFS["languageId.minSpeechMs"], + ).toMatchObject({ + default: 1500, + min: 1000, + max: 5000, + }); + expect(VOICE_ADVANCED_SETTING_DEFS["languageId.timing"].default).toBe( + "early", + ); + setProjectVoiceAdvancedSetting.mockResolvedValue({ + project_id: "proj-1", + settings: {}, + }); + await expect(set("languageId.minSpeechMs", "999")).rejects.toThrow( + /between 1000 and 5000/, + ); + }); + + it("accepts finalHoldMs up to 10000 like the dashboard", async () => { + setProjectVoiceAdvancedSetting.mockResolvedValue({ + project_id: "proj-1", + settings: {}, + }); + await set("languageId.autoSwitch.finalHoldMs", "10000"); + await expect( + set("languageId.autoSwitch.finalHoldMs", "10001"), + ).rejects.toThrow(/between 0 and 10000/); + }); + + it("lists the wait settings from the flat API shape", async () => { + listProjectVoiceAdvancedSettings.mockResolvedValue({ + project_id: "proj-1", + settings: { + languageId: { + autoSwitch: { + waitAudio: "first_utterance", + waitMessageMode: "off", + waitMessageSkipWhenReady: false, + waitMessageTexts: '{"en":"Hi."}', + }, + }, + }, + }); + await runProjectsVoiceAdvancedList({}); + expect(console.log).toHaveBeenCalledWith( + "languageId.autoSwitch.waitAudio=first_utterance", + ); + expect(console.log).toHaveBeenCalledWith( + "languageId.autoSwitch.waitMessage.mode=off", + ); + expect(console.log).toHaveBeenCalledWith( + "languageId.autoSwitch.waitMessage.skipWhenReady=false", + ); + expect(console.log).toHaveBeenCalledWith( + 'languageId.autoSwitch.waitMessage.texts={"en":"Hi."}', + ); + }); + }); + it("resets advanced settings", async () => { resetProjectVoiceAdvancedSettings.mockResolvedValue({ project_id: "proj-1", diff --git a/src/commands/projects/voice-advanced/defs.ts b/src/commands/projects/voice-advanced/defs.ts index 0083fb1..bc87d45 100644 --- a/src/commands/projects/voice-advanced/defs.ts +++ b/src/commands/projects/voice-advanced/defs.ts @@ -1,3 +1,21 @@ +/** + * Keep in step with the platform (`platform/src/lib/voice/language-switch-wait-messages.ts`): + * same default texts, 300 character limit and ISO 639-1 keys. The server validates the + * languages and model ids; the CLI checks the shape so mistakes fail before the request. + */ +const WAIT_MESSAGE_MAX_CHARS = 300; +const DEFAULT_WAIT_MESSAGES_JSON = JSON.stringify({ + de: "Einen Moment bitte, ich wechsle gerade in Ihre Sprache.", + en: "Please wait a moment while I switch to your language.", + es: "Un momento, por favor, estoy cambiando a su idioma.", + fr: "Un instant, s'il vous plaît, je passe dans votre langue.", + it: "Un momento, per favore, sto passando alla sua lingua.", + nl: "Een ogenblik, ik schakel over naar uw taal.", + pl: "Chwileczkę, przełączam się na Twój język.", + pt: "Um momento, por favor, estou mudando para o seu idioma.", + ru: "Одну минуту, я перехожу на ваш язык.", +}); + export const VOICE_ADVANCED_SETTING_KEYS = [ "vad.enabled", "vad.provider", @@ -22,13 +40,17 @@ export const VOICE_ADVANCED_SETTING_KEYS = [ "noiseSuppression.enabled", "languageId.enabled", "languageId.minSpeechMs", + "languageId.timing", "languageId.autoSwitch.enabled", "languageId.autoSwitch.replayLastUtterance", "languageId.autoSwitch.finalHoldMs", "languageId.autoSwitch.minDwellMs", "languageId.autoSwitch.confirmUtterances", "languageId.autoSwitch.switchVoice", - "voice.expectedLanguages", + "languageId.autoSwitch.waitAudio", + "languageId.autoSwitch.waitMessage.mode", + "languageId.autoSwitch.waitMessage.skipWhenReady", + "languageId.autoSwitch.waitMessage.texts", "voice.allowedLanguages", "voice.profilesByLanguage", "events.mode", @@ -188,11 +210,18 @@ export const VOICE_ADVANCED_SETTING_DEFS: Record< }, "languageId.minSpeechMs": { type: "number", - default: 2500, + default: 1500, min: 1000, max: 5000, description: - "Minimum inbound PCM (ms) before first spoken-language identify per utterance (cloud default 2500).", + "Minimum inbound PCM (ms) before first spoken-language identify per utterance (cloud default 1500). Shorter utterances skip language ID.", + }, + "languageId.timing": { + type: "string", + default: "early", + enum: ["early", "end_of_utterance", "continuous"], + description: + "When to identify the spoken language: early (once, as soon as minSpeechMs is buffered, while the caller talks), end_of_utterance (once, after the caller stops) or continuous (repeated passes; most CPU, turn off if agent audio gets choppy).", }, "languageId.autoSwitch.enabled": { type: "boolean", @@ -207,47 +236,70 @@ export const VOICE_ADVANCED_SETTING_DEFS: Record< }, "languageId.autoSwitch.finalHoldMs": { type: "number", - default: 500, + default: 5000, min: 0, - max: 1500, - description: "Max ms to hold user_speech_final while waiting for user_language.", + max: 10_000, + description: + "Max ms to hold user_speech_final while waiting for user_language.", }, "languageId.autoSwitch.minDwellMs": { type: "number", default: 10_000, min: 0, max: 60_000, - description: "Ignore LID flips for this long after a committed auto-switch.", + description: + "Ignore LID flips for this long after a committed auto-switch.", }, "languageId.autoSwitch.confirmUtterances": { type: "number", default: 1, min: 1, max: 3, - description: "Consecutive user_language events required before auto-switch.", + description: + "Consecutive user_language events required before auto-switch.", }, "languageId.autoSwitch.switchVoice": { type: "boolean", default: true, description: "Switch TTS as well as STT on auto-switch (false = STT only).", }, - "voice.expectedLanguages": { + "languageId.autoSwitch.waitAudio": { type: "string", - default: "", + default: "buffer_replay", + enum: ["buffer_replay", "first_utterance"], + description: + "Speech kept while the new language loads: buffer_replay replays everything said during the wait, first_utterance replays only the utterance that triggered language ID.", + }, + "languageId.autoSwitch.waitMessage.mode": { + type: "string", + default: "end_of_utterance", + enum: ["end_of_utterance", "immediate", "off"], description: - "Comma-separated ISO 639-1 codes to pre-warm at deploy (empty = boot language).", + "When the runner plays the wait message while the target language loads: end_of_utterance, immediate (can cut the caller off), or off.", + }, + "languageId.autoSwitch.waitMessage.skipWhenReady": { + type: "boolean", + default: true, + description: + "Skip the wait message when the target language is already running.", + }, + "languageId.autoSwitch.waitMessage.texts": { + type: "string", + default: DEFAULT_WAIT_MESSAGES_JSON, + description: + 'JSON map of ISO 639-1 code to wait text (max 300 chars each), spoken in the language being left, e.g. {"en":"One moment, switching to your language."}.', }, "voice.allowedLanguages": { type: "string", default: "", description: - "Allowed languages for setVoiceLanguage / LID auto-switch (* = any for agent API).", + "Allowed languages for setVoiceLanguage / LID auto-switch (* = any for agent API). Empty = boot language plus the languages in voice.profilesByLanguage.", }, "voice.profilesByLanguage": { type: "string", default: "{}", description: - 'JSON presets per language, e.g. {"de":{"stt":"de","tts":"de-thorsten-high"}}.', + 'STT/TTS model per language used after a switch, as JSON, e.g. {"de":{"stt":"de","tts":"de-thorsten-high"}}.', }, "events.mode": { type: "string", @@ -295,11 +347,78 @@ export function formatVoiceAdvancedSettingsGroupHelp(): string { lines.push( " $ voicethere projects voice-advanced set languageId.autoSwitch.enabled true", ); + lines.push( + " $ voicethere projects voice-advanced set languageId.autoSwitch.waitMessage.mode immediate", + ); + lines.push( + ' $ voicethere projects voice-advanced set languageId.autoSwitch.waitMessage.texts \'{"en":"One moment, switching to your language."}\'', + ); lines.push(" $ voicethere projects voice-advanced reset"); return lines.join("\n"); } +const ISO_LANG_RE = /^[a-z]{2}$/; + +function parseJsonObject(key: string, raw: string): Record { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error(`${key} must be a valid JSON object`); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`${key} must be a JSON object keyed by language code`); + } + return parsed as Record; +} + +function validateWaitMessageTexts(raw: string): string { + const key = "languageId.autoSwitch.waitMessage.texts"; + const obj = parseJsonObject(key, raw || "{}"); + for (const [lang, text] of Object.entries(obj)) { + if (!ISO_LANG_RE.test(lang)) { + throw new Error( + `${key}: invalid language key "${lang}" (use ISO 639-1, e.g. en)`, + ); + } + if (typeof text !== "string" || !text.trim()) { + throw new Error(`${key}.${lang} must be a non-empty string`); + } + if ([...text.trim()].length > WAIT_MESSAGE_MAX_CHARS) { + throw new Error( + `${key}.${lang} must be at most ${WAIT_MESSAGE_MAX_CHARS} characters`, + ); + } + } + return raw || "{}"; +} + +function validateProfilesByLanguage(raw: string): string { + const key = "voice.profilesByLanguage"; + const obj = parseJsonObject(key, raw || "{}"); + for (const [lang, entry] of Object.entries(obj)) { + if (!ISO_LANG_RE.test(lang)) { + throw new Error( + `${key}: invalid language key "${lang}" (use ISO 639-1, e.g. en)`, + ); + } + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error(`${key}.${lang} must be an object`); + } + const row = entry as Record; + for (const field of ["stt", "tts", "voice", "sttVendor", "ttsVendor"]) { + if (row[field] !== undefined && typeof row[field] !== "string") { + throw new Error(`${key}.${lang}.${field} must be a string`); + } + } + if (Object.keys(row).length === 0) { + throw new Error(`${key}.${lang} is empty`); + } + } + return raw || "{}"; +} + export function parseVoiceAdvancedSettingValue( key: VoiceAdvancedSettingKey, raw: string, @@ -311,6 +430,12 @@ export function parseVoiceAdvancedSettingValue( if (def.enum && !def.enum.includes(trimmed)) { throw new Error(`${key} must be one of: ${def.enum.join(", ")}`); } + if (key === "languageId.autoSwitch.waitMessage.texts") { + return validateWaitMessageTexts(trimmed); + } + if (key === "voice.profilesByLanguage") { + return validateProfilesByLanguage(trimmed); + } return trimmed; } @@ -338,11 +463,35 @@ export function parseVoiceAdvancedSettingValue( return def.type === "number" && keepFractional ? n : Math.floor(n); } +/** + * The API returns the wait message settings flat under `languageId.autoSwitch` + * (`waitMessageMode`), while the setting keys use a dotted `waitMessage.` prefix. + */ +const RESOLVED_PATH_OVERRIDES: Partial< + Record +> = { + "languageId.autoSwitch.waitMessage.mode": [ + "languageId", + "autoSwitch", + "waitMessageMode", + ], + "languageId.autoSwitch.waitMessage.skipWhenReady": [ + "languageId", + "autoSwitch", + "waitMessageSkipWhenReady", + ], + "languageId.autoSwitch.waitMessage.texts": [ + "languageId", + "autoSwitch", + "waitMessageTexts", + ], +}; + function getNestedValue( settings: Record, key: VoiceAdvancedSettingKey, ): unknown { - const parts = key.split("."); + const parts = RESOLVED_PATH_OVERRIDES[key] ?? key.split("."); let current: unknown = settings; for (const part of parts) { if (!current || typeof current !== "object") { diff --git a/src/lib/api.ts b/src/lib/api.ts index aaaf543..eb9d748 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -349,13 +349,17 @@ export type VoiceAdvancedSettingKey = | "noiseSuppression.enabled" | "languageId.enabled" | "languageId.minSpeechMs" + | "languageId.timing" | "languageId.autoSwitch.enabled" | "languageId.autoSwitch.replayLastUtterance" | "languageId.autoSwitch.finalHoldMs" | "languageId.autoSwitch.minDwellMs" | "languageId.autoSwitch.confirmUtterances" | "languageId.autoSwitch.switchVoice" - | "voice.expectedLanguages" + | "languageId.autoSwitch.waitAudio" + | "languageId.autoSwitch.waitMessage.mode" + | "languageId.autoSwitch.waitMessage.skipWhenReady" + | "languageId.autoSwitch.waitMessage.texts" | "voice.allowedLanguages" | "voice.profilesByLanguage";