fix: validate minimax speech voice settings

This commit is contained in:
Peter Steinberger
2026-05-28 17:26:29 -04:00
parent d9452e6acb
commit c84d53ccfe
5 changed files with 101 additions and 20 deletions

View File

@@ -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<string, unknown>;
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");

View File

@@ -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 }) =>

View File

@@ -15,6 +15,7 @@ export {
readStringValue,
} from "../shared/string-coerce.js";
export {
asFiniteNumberInRange,
asFiniteNumber,
asPositiveSafeInteger,
parseFiniteNumber,

View File

@@ -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);

View File

@@ -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;