Skip to content

Commit 015bfd9

Browse files
committed
fix(speech): keep TTS truncation UTF-16 safe
1 parent 632efa2 commit 015bfd9

2 files changed

Lines changed: 58 additions & 4 deletions

File tree

packages/speech-core/src/tts.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ const {
115115
getTtsProvider,
116116
maybeApplyTtsToPayload,
117117
resolveTtsConfig,
118+
setSummarizationEnabled,
119+
setTtsMaxLength,
118120
synthesizeSpeech,
119121
textToSpeechTelephony,
120122
} = await import("./tts.js");
@@ -174,6 +176,24 @@ function requireFirstSynthesisRequest(label: string): Record<string, unknown> {
174176
return requireRecord(requireFirstCallParam(synthesizeMock.mock.calls, label), label);
175177
}
176178

179+
function hasLoneSurrogate(value: string): boolean {
180+
for (let index = 0; index < value.length; index += 1) {
181+
const code = value.charCodeAt(index);
182+
if (code >= 0xd800 && code <= 0xdbff) {
183+
const next = value.charCodeAt(index + 1);
184+
if (!(next >= 0xdc00 && next <= 0xdfff)) {
185+
return true;
186+
}
187+
index += 1;
188+
continue;
189+
}
190+
if (code >= 0xdc00 && code <= 0xdfff) {
191+
return true;
192+
}
193+
}
194+
return false;
195+
}
196+
177197
function requireAttempt(attempts: unknown[] | undefined, index: number) {
178198
if (!attempts) {
179199
throw new Error("expected synthesis attempts");
@@ -938,6 +958,36 @@ describe("speech-core native voice-note routing", () => {
938958
}
939959
});
940960

961+
it("truncates long TTS text on a UTF-16 boundary", async () => {
962+
const prefsName = "openclaw-speech-core-utf16-truncate-test";
963+
const prefsPath = `/tmp/${prefsName}.json`;
964+
const cfg = createTtsConfig(prefsName);
965+
setTtsMaxLength(prefsPath, 11);
966+
setSummarizationEnabled(prefsPath, false);
967+
let mediaDir: string | undefined;
968+
try {
969+
const result = await maybeApplyTtsToPayload({
970+
payload: { text: `${"a".repeat(7)}😀tail long enough for TTS` },
971+
cfg,
972+
channel: "telegram",
973+
kind: "final",
974+
});
975+
976+
expect(synthesizeMock).toHaveBeenCalled();
977+
const request = requireFirstSynthesisRequest("utf16 truncated TTS request");
978+
const spokenText = String(request.text);
979+
expect(hasLoneSurrogate(spokenText)).toBe(false);
980+
expect(spokenText).toBe(`${"a".repeat(7)}...`);
981+
expect(result.spokenText).toBe(spokenText);
982+
mediaDir = result.mediaUrl ? path.dirname(result.mediaUrl) : undefined;
983+
} finally {
984+
rmSync(prefsPath, { force: true });
985+
if (mediaDir) {
986+
rmSync(mediaDir, { recursive: true, force: true });
987+
}
988+
}
989+
});
990+
941991
it("skips block delivery kind in final mode (accumulated final tail synthesizes instead)", async () => {
942992
synthesizeMock.mockClear();
943993
const cfg = createTtsConfig("openclaw-speech-core-block-kind-tts-test");

packages/speech-core/src/tts.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@ import {
3333
normalizeOptionalString,
3434
} from "openclaw/plugin-sdk/string-coerce-runtime";
3535
import { stripMarkdown } from "openclaw/plugin-sdk/text-chunking";
36-
import { resolveConfigDir, resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime";
36+
import {
37+
resolveConfigDir,
38+
resolveUserPath,
39+
truncateUtf16Safe,
40+
} from "openclaw/plugin-sdk/text-utility-runtime";
3741
import {
3842
canonicalizeSpeechProviderId,
3943
getSpeechProvider,
@@ -2028,7 +2032,7 @@ export async function maybeApplyTtsToPayload(params: {
20282032
logVerbose(
20292033
`TTS: truncating long text (${textForAudio.length} > ${maxLength}), summarization disabled.`,
20302034
);
2031-
textForAudio = `${textForAudio.slice(0, maxLength - 3)}...`;
2035+
textForAudio = `${truncateUtf16Safe(textForAudio, maxLength - 3)}...`;
20322036
} else {
20332037
try {
20342038
const summary = await summarizeText({
@@ -2044,12 +2048,12 @@ export async function maybeApplyTtsToPayload(params: {
20442048
logVerbose(
20452049
`TTS: summary exceeded hard limit (${textForAudio.length} > ${config.maxTextLength}); truncating.`,
20462050
);
2047-
textForAudio = `${textForAudio.slice(0, config.maxTextLength - 3)}...`;
2051+
textForAudio = `${truncateUtf16Safe(textForAudio, config.maxTextLength - 3)}...`;
20482052
}
20492053
} catch (err) {
20502054
const error = err as Error;
20512055
logVerbose(`TTS: summarization failed, truncating instead: ${error.message}`);
2052-
textForAudio = `${textForAudio.slice(0, maxLength - 3)}...`;
2056+
textForAudio = `${truncateUtf16Safe(textForAudio, maxLength - 3)}...`;
20532057
}
20542058
}
20552059
}

0 commit comments

Comments
 (0)