@@ -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+
177197function 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" ) ;
0 commit comments