diff --git a/extensions/minimax/speech-provider.test.ts b/extensions/minimax/speech-provider.test.ts index a30f9e9d20a..5bf102d1526 100644 --- a/extensions/minimax/speech-provider.test.ts +++ b/extensions/minimax/speech-provider.test.ts @@ -449,6 +449,32 @@ describe("buildMinimaxSpeechProvider", () => { expect(voiceSetting.pitch).toBe(0); }); + it("drops malformed voice settings before synthesis", async () => { + const hexAudio = Buffer.from("audio").toString("hex"); + const mockFetch = vi.mocked(globalThis.fetch); + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ data: { audio: hexAudio } }), { status: 200 }), + ); + + await provider.synthesize({ + text: "Test", + cfg: {} as never, + providerConfig: { + apiKey: "sk-test", + speed: 3, + vol: -1, + pitch: 20, + }, + target: "audio-file", + timeoutMs: 30000, + }); + + const voiceSetting = firstFetchBody().voice_setting as Record; + expect(voiceSetting.speed).toBe(1); + expect(voiceSetting.vol).toBe(1); + expect(voiceSetting.pitch).toBe(0); + }); + it("uses a MiniMax Token Plan env var when no API key is configured", async () => { process.env.MINIMAX_CODING_API_KEY = "sk-cp-env"; const hexAudio = Buffer.from("audio").toString("hex"); diff --git a/extensions/minimax/speech-provider.ts b/extensions/minimax/speech-provider.ts index 7483d9cbcdc..9d27901284f 100644 --- a/extensions/minimax/speech-provider.ts +++ b/extensions/minimax/speech-provider.ts @@ -11,7 +11,8 @@ import type { SpeechProviderOverrides, SpeechProviderPlugin, } from "openclaw/plugin-sdk/speech-core"; -import { asFiniteNumber, asObject, trimToUndefined } from "openclaw/plugin-sdk/speech-core"; +import { asObject, trimToUndefined } from "openclaw/plugin-sdk/speech-core"; +import { asFiniteNumberInRange } from "openclaw/plugin-sdk/string-coerce-runtime"; import { DEFAULT_MINIMAX_TTS_BASE_URL, MINIMAX_TTS_MODELS, @@ -108,12 +109,25 @@ function normalizeMinimaxProviderConfig( trimToUndefined(raw?.voiceId) ?? trimToUndefined(process.env.MINIMAX_TTS_VOICE_ID) ?? "English_expressive_narrator", - speed: asFiniteNumber(raw?.speed), - vol: asFiniteNumber(raw?.vol), - pitch: asFiniteNumber(raw?.pitch), + speed: normalizeMinimaxSpeed(raw?.speed), + vol: normalizeMinimaxVolume(raw?.vol), + pitch: normalizeMinimaxPitch(raw?.pitch), }; } +function normalizeMinimaxSpeed(value: unknown): number | undefined { + return asFiniteNumberInRange(value, { min: 0.5, max: 2 }); +} + +function normalizeMinimaxVolume(value: unknown): number | undefined { + return asFiniteNumberInRange(value, { min: 0, max: 10, minExclusive: true }); +} + +function normalizeMinimaxPitch(value: unknown): number | undefined { + const pitch = asFiniteNumberInRange(value, { min: -12, max: 12 }); + return pitch !== undefined ? Math.trunc(pitch) : undefined; +} + function readMinimaxProviderConfig( config: SpeechProviderConfig, cfg?: OpenClawConfig, @@ -124,9 +138,9 @@ function readMinimaxProviderConfig( baseUrl: normalizeMinimaxTtsBaseUrl(trimToUndefined(config.baseUrl) ?? normalized.baseUrl), model: trimToUndefined(config.model) ?? normalized.model, voiceId: trimToUndefined(config.voiceId) ?? normalized.voiceId, - speed: asFiniteNumber(config.speed) ?? normalized.speed, - vol: asFiniteNumber(config.vol) ?? normalized.vol, - pitch: asFiniteNumber(config.pitch) ?? normalized.pitch, + speed: normalizeMinimaxSpeed(config.speed) ?? normalized.speed, + vol: normalizeMinimaxVolume(config.vol) ?? normalized.vol, + pitch: normalizeMinimaxPitch(config.pitch) ?? normalized.pitch, }; } @@ -139,9 +153,9 @@ function readMinimaxOverrides( return { model: trimToUndefined(overrides.model), voiceId: trimToUndefined(overrides.voiceId), - speed: asFiniteNumber(overrides.speed), - vol: asFiniteNumber(overrides.vol), - pitch: asFiniteNumber(overrides.pitch), + speed: normalizeMinimaxSpeed(overrides.speed), + vol: normalizeMinimaxVolume(overrides.vol), + pitch: normalizeMinimaxPitch(overrides.pitch), }; } @@ -236,15 +250,15 @@ export function buildMinimaxSpeechProvider(): SpeechProviderPlugin { ...(trimToUndefined(talkProviderConfig.voiceId) == null ? {} : { voiceId: trimToUndefined(talkProviderConfig.voiceId) }), - ...(asFiniteNumber(talkProviderConfig.speed) == null + ...(normalizeMinimaxSpeed(talkProviderConfig.speed) == null ? {} - : { speed: asFiniteNumber(talkProviderConfig.speed) }), - ...(asFiniteNumber(talkProviderConfig.vol) == null + : { speed: normalizeMinimaxSpeed(talkProviderConfig.speed) }), + ...(normalizeMinimaxVolume(talkProviderConfig.vol) == null ? {} - : { vol: asFiniteNumber(talkProviderConfig.vol) }), - ...(asFiniteNumber(talkProviderConfig.pitch) == null + : { vol: normalizeMinimaxVolume(talkProviderConfig.vol) }), + ...(normalizeMinimaxPitch(talkProviderConfig.pitch) == null ? {} - : { pitch: asFiniteNumber(talkProviderConfig.pitch) }), + : { pitch: normalizeMinimaxPitch(talkProviderConfig.pitch) }), }; }, resolveTalkOverrides: ({ params }) => ({ @@ -254,9 +268,15 @@ export function buildMinimaxSpeechProvider(): SpeechProviderPlugin { ...(trimToUndefined(params.modelId) == null ? {} : { model: trimToUndefined(params.modelId) }), - ...(asFiniteNumber(params.speed) == null ? {} : { speed: asFiniteNumber(params.speed) }), - ...(asFiniteNumber(params.vol) == null ? {} : { vol: asFiniteNumber(params.vol) }), - ...(asFiniteNumber(params.pitch) == null ? {} : { pitch: asFiniteNumber(params.pitch) }), + ...(normalizeMinimaxSpeed(params.speed) == null + ? {} + : { speed: normalizeMinimaxSpeed(params.speed) }), + ...(normalizeMinimaxVolume(params.vol) == null + ? {} + : { vol: normalizeMinimaxVolume(params.vol) }), + ...(normalizeMinimaxPitch(params.pitch) == null + ? {} + : { pitch: normalizeMinimaxPitch(params.pitch) }), }), listVoices: async () => MINIMAX_TTS_VOICES.map((voice) => ({ id: voice, name: voice })), isConfigured: ({ cfg, providerConfig }) => diff --git a/src/plugin-sdk/string-coerce-runtime.ts b/src/plugin-sdk/string-coerce-runtime.ts index 8aae2968b30..98a4490a5e9 100644 --- a/src/plugin-sdk/string-coerce-runtime.ts +++ b/src/plugin-sdk/string-coerce-runtime.ts @@ -15,6 +15,7 @@ export { readStringValue, } from "../shared/string-coerce.js"; export { + asFiniteNumberInRange, asFiniteNumber, asPositiveSafeInteger, parseFiniteNumber, diff --git a/src/shared/number-coercion.test.ts b/src/shared/number-coercion.test.ts index 7526fc9b4d5..2cb90b85229 100644 --- a/src/shared/number-coercion.test.ts +++ b/src/shared/number-coercion.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { asFiniteNumber, parseFiniteNumber } from "./number-coercion.js"; +import { asFiniteNumber, asFiniteNumberInRange, parseFiniteNumber } from "./number-coercion.js"; describe("number-coercion", () => { test("asFiniteNumber accepts only finite numbers", () => { @@ -9,6 +9,14 @@ describe("number-coercion", () => { expect(asFiniteNumber(Number.POSITIVE_INFINITY)).toBeUndefined(); }); + test("asFiniteNumberInRange enforces inclusive and exclusive bounds", () => { + expect(asFiniteNumberInRange(0.5, { min: 0.5, max: 2 })).toBe(0.5); + expect(asFiniteNumberInRange(2, { min: 0.5, max: 2 })).toBe(2); + expect(asFiniteNumberInRange(0.5, { min: 0.5, minExclusive: true })).toBeUndefined(); + expect(asFiniteNumberInRange(10, { max: 10, maxExclusive: true })).toBeUndefined(); + expect(asFiniteNumberInRange("1", { min: 0, max: 2 })).toBeUndefined(); + }); + test("parseFiniteNumber accepts finite numbers and numeric strings", () => { expect(parseFiniteNumber(4)).toBe(4); expect(parseFiniteNumber("4.5")).toBe(4.5); diff --git a/src/shared/number-coercion.ts b/src/shared/number-coercion.ts index f8a36f6b3a7..abeb059d5e8 100644 --- a/src/shared/number-coercion.ts +++ b/src/shared/number-coercion.ts @@ -2,6 +2,32 @@ export function asFiniteNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +export function asFiniteNumberInRange( + value: unknown, + range: { + min?: number; + max?: number; + minExclusive?: boolean; + maxExclusive?: boolean; + }, +): number | undefined { + const number = asFiniteNumber(value); + if (number === undefined) { + return undefined; + } + if (range.min !== undefined) { + if (range.minExclusive ? number <= range.min : number < range.min) { + return undefined; + } + } + if (range.max !== undefined) { + if (range.maxExclusive ? number >= range.max : number > range.max) { + return undefined; + } + } + return number; +} + export function parseFiniteNumber(value: unknown): number | undefined { if (typeof value === "number") { return Number.isFinite(value) ? value : undefined;