From 9e30fc865a29a7a214e673be979abf24a3f2bcc6 Mon Sep 17 00:00:00 2001 From: akirilyuk Date: Sun, 4 Oct 2026 09:46:17 +0700 Subject: [PATCH 1/3] feat(voice-advanced): language switch wait settings, drop expectedLanguages Add languageId.autoSwitch.waitAudio, waitMessage.mode, waitMessage.skipWhenReady and waitMessage.texts. Check voice.profilesByLanguage and the wait texts for JSON shape before the request. Remove voice.expectedLanguages. Align finalHoldMs default (5000) and max (10000) with the dashboard. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 ++ README.md | 24 +++ .../projects/voice-advanced/commands.test.ts | 171 +++++++++++++++++- src/commands/projects/voice-advanced/defs.ts | 165 +++++++++++++++-- src/lib/api.ts | 5 +- 5 files changed, 363 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afafc77..64db0d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ 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`** — `voice.profilesByLanguage` and the wait texts are checked for JSON shape before the request is sent. + +### Changed + +- **`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..48b0114 100644 --- a/README.md +++ b/README.md @@ -390,6 +390,30 @@ 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.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.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..357606e 100644 --- a/src/commands/projects/voice-advanced/commands.test.ts +++ b/src/commands/projects/voice-advanced/commands.test.ts @@ -48,9 +48,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(36); + 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 +154,160 @@ 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("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..7431f27 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", @@ -28,7 +46,10 @@ export const VOICE_ADVANCED_SETTING_KEYS = [ "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", @@ -207,47 +228,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: - "Comma-separated ISO 639-1 codes to pre-warm at deploy (empty = boot language).", + "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: + "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 +339,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 +422,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 +455,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..0ad98f4 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -355,7 +355,10 @@ export type VoiceAdvancedSettingKey = | "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"; From 9c45cd011187cdc49eef5097d9a3cb71274148ab Mon Sep 17 00:00:00 2001 From: akirilyuk Date: Sun, 4 Oct 2026 11:03:47 +0700 Subject: [PATCH 2/3] feat(voice-advanced): languageId.continuous, minSpeechMs default 1500 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + README.md | 2 + .../projects/voice-advanced/commands.test.ts | 50 ++++++++++++++++++- src/commands/projects/voice-advanced/defs.ts | 11 +++- src/lib/api.ts | 1 + 5 files changed, 62 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64db0d4..1773c1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). Versioning foll ### 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.continuous` (default `true`): identify the spoken language while the caller is still speaking. - **`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 diff --git a/README.md b/README.md index 48b0114..38d0c33 100644 --- a/README.md +++ b/README.md @@ -397,6 +397,7 @@ Spoken-language auto-switch and its wait behavior are advanced voice settings. T ```bash voicethere projects voice-advanced list voicethere projects voice-advanced set languageId.autoSwitch.enabled true +voicethere projects voice-advanced set languageId.continuous false 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 @@ -406,6 +407,7 @@ voicethere projects voice-advanced set voice.profilesByLanguage '{"de":{"stt":"d | Key | Values | Default | | ------------------------------------------------- | ---------------------------------------------- | ------------------ | +| `languageId.continuous` | bool | `true` | | `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` | diff --git a/src/commands/projects/voice-advanced/commands.test.ts b/src/commands/projects/voice-advanced/commands.test.ts index 357606e..73c996d 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, @@ -52,7 +55,7 @@ describe("projects voice-advanced commands", () => { "languageId.autoSwitch.enabled", ); expect(VOICE_ADVANCED_SETTING_KEYS).toContain("voice.profilesByLanguage"); - expect(VOICE_ADVANCED_SETTING_KEYS).toHaveLength(36); + expect(VOICE_ADVANCED_SETTING_KEYS).toHaveLength(37); expect(VOICE_ADVANCED_SETTING_KEYS).not.toContain( "voice.expectedLanguages", ); @@ -267,6 +270,49 @@ describe("projects voice-advanced commands", () => { await expect(set(key, '{"de":{}}')).rejects.toThrow(/is empty/); }); + it("sets languageId.continuous as a boolean and rejects other values", async () => { + expect(VOICE_ADVANCED_SETTING_KEYS).toContain("languageId.continuous"); + setProjectVoiceAdvancedSetting.mockResolvedValue({ + project_id: "proj-1", + settings: {}, + }); + await set("languageId.continuous", "false"); + expect(setProjectVoiceAdvancedSetting).toHaveBeenLastCalledWith( + "proj-1", + "languageId.continuous", + false, + ); + await set("languageId.continuous", "yes"); + expect(setProjectVoiceAdvancedSetting).toHaveBeenLastCalledWith( + "proj-1", + "languageId.continuous", + true, + ); + await expect(set("languageId.continuous", "sometimes")).rejects.toThrow( + /Invalid boolean/, + ); + }); + + 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.continuous"].default).toBe( + true, + ); + 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", diff --git a/src/commands/projects/voice-advanced/defs.ts b/src/commands/projects/voice-advanced/defs.ts index 7431f27..d54238f 100644 --- a/src/commands/projects/voice-advanced/defs.ts +++ b/src/commands/projects/voice-advanced/defs.ts @@ -40,6 +40,7 @@ export const VOICE_ADVANCED_SETTING_KEYS = [ "noiseSuppression.enabled", "languageId.enabled", "languageId.minSpeechMs", + "languageId.continuous", "languageId.autoSwitch.enabled", "languageId.autoSwitch.replayLastUtterance", "languageId.autoSwitch.finalHoldMs", @@ -209,11 +210,17 @@ 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.continuous": { + type: "boolean", + default: true, + description: + "Identify the spoken language while the caller is still speaking, as soon as minSpeechMs of speech is buffered (false = once at the end of each utterance). Uses extra CPU.", }, "languageId.autoSwitch.enabled": { type: "boolean", diff --git a/src/lib/api.ts b/src/lib/api.ts index 0ad98f4..5cc4d14 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -349,6 +349,7 @@ export type VoiceAdvancedSettingKey = | "noiseSuppression.enabled" | "languageId.enabled" | "languageId.minSpeechMs" + | "languageId.continuous" | "languageId.autoSwitch.enabled" | "languageId.autoSwitch.replayLastUtterance" | "languageId.autoSwitch.finalHoldMs" From 396f58f571ab8c158269a482a3e221f963b2ee78 Mon Sep 17 00:00:00 2001 From: akirilyuk Date: Sun, 4 Oct 2026 11:20:49 +0700 Subject: [PATCH 3/3] feat(voice-advanced): replace languageId.continuous with languageId.timing Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- README.md | 4 +-- .../projects/voice-advanced/commands.test.ts | 35 +++++++++---------- src/commands/projects/voice-advanced/defs.ts | 11 +++--- src/lib/api.ts | 2 +- 5 files changed, 27 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1773c1e..60170e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). Versioning foll ### 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.continuous` (default `true`): identify the spoken language while the caller is still speaking. +- **`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 diff --git a/README.md b/README.md index 38d0c33..b15d814 100644 --- a/README.md +++ b/README.md @@ -397,7 +397,7 @@ Spoken-language auto-switch and its wait behavior are advanced voice settings. T ```bash voicethere projects voice-advanced list voicethere projects voice-advanced set languageId.autoSwitch.enabled true -voicethere projects voice-advanced set languageId.continuous false +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 @@ -407,7 +407,7 @@ voicethere projects voice-advanced set voice.profilesByLanguage '{"de":{"stt":"d | Key | Values | Default | | ------------------------------------------------- | ---------------------------------------------- | ------------------ | -| `languageId.continuous` | bool | `true` | +| `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` | diff --git a/src/commands/projects/voice-advanced/commands.test.ts b/src/commands/projects/voice-advanced/commands.test.ts index 73c996d..5519c32 100644 --- a/src/commands/projects/voice-advanced/commands.test.ts +++ b/src/commands/projects/voice-advanced/commands.test.ts @@ -270,26 +270,25 @@ describe("projects voice-advanced commands", () => { await expect(set(key, '{"de":{}}')).rejects.toThrow(/is empty/); }); - it("sets languageId.continuous as a boolean and rejects other values", async () => { - expect(VOICE_ADVANCED_SETTING_KEYS).toContain("languageId.continuous"); + 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: {}, }); - await set("languageId.continuous", "false"); - expect(setProjectVoiceAdvancedSetting).toHaveBeenLastCalledWith( - "proj-1", - "languageId.continuous", - false, - ); - await set("languageId.continuous", "yes"); - expect(setProjectVoiceAdvancedSetting).toHaveBeenLastCalledWith( - "proj-1", - "languageId.continuous", - true, - ); - await expect(set("languageId.continuous", "sometimes")).rejects.toThrow( - /Invalid boolean/, + 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/, ); }); @@ -301,8 +300,8 @@ describe("projects voice-advanced commands", () => { min: 1000, max: 5000, }); - expect(VOICE_ADVANCED_SETTING_DEFS["languageId.continuous"].default).toBe( - true, + expect(VOICE_ADVANCED_SETTING_DEFS["languageId.timing"].default).toBe( + "early", ); setProjectVoiceAdvancedSetting.mockResolvedValue({ project_id: "proj-1", diff --git a/src/commands/projects/voice-advanced/defs.ts b/src/commands/projects/voice-advanced/defs.ts index d54238f..bc87d45 100644 --- a/src/commands/projects/voice-advanced/defs.ts +++ b/src/commands/projects/voice-advanced/defs.ts @@ -40,7 +40,7 @@ export const VOICE_ADVANCED_SETTING_KEYS = [ "noiseSuppression.enabled", "languageId.enabled", "languageId.minSpeechMs", - "languageId.continuous", + "languageId.timing", "languageId.autoSwitch.enabled", "languageId.autoSwitch.replayLastUtterance", "languageId.autoSwitch.finalHoldMs", @@ -216,11 +216,12 @@ export const VOICE_ADVANCED_SETTING_DEFS: Record< description: "Minimum inbound PCM (ms) before first spoken-language identify per utterance (cloud default 1500). Shorter utterances skip language ID.", }, - "languageId.continuous": { - type: "boolean", - default: true, + "languageId.timing": { + type: "string", + default: "early", + enum: ["early", "end_of_utterance", "continuous"], description: - "Identify the spoken language while the caller is still speaking, as soon as minSpeechMs of speech is buffered (false = once at the end of each utterance). Uses extra CPU.", + "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", diff --git a/src/lib/api.ts b/src/lib/api.ts index 5cc4d14..eb9d748 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -349,7 +349,7 @@ export type VoiceAdvancedSettingKey = | "noiseSuppression.enabled" | "languageId.enabled" | "languageId.minSpeechMs" - | "languageId.continuous" + | "languageId.timing" | "languageId.autoSwitch.enabled" | "languageId.autoSwitch.replayLastUtterance" | "languageId.autoSwitch.finalHoldMs"