From 2ebf0422dbfd95543318c8fbc0f11bcf32f6e8cf Mon Sep 17 00:00:00 2001 From: dwc1997 <66739829+dwc1997@users.noreply.github.com> Date: Mon, 13 Jul 2026 01:47:05 +0800 Subject: [PATCH] fix(discord): keep realtime partial transcript tail UTF-16 safe (#104896) * fix(discord): keep realtime partial transcript tail UTF-16 safe * refactor(discord): isolate realtime transcript merging --------- Co-authored-by: Peter Steinberger --- .../src/voice/realtime-transcript.test.ts | 30 +++++++++++++++++++ .../discord/src/voice/realtime-transcript.ts | 13 ++++++++ extensions/discord/src/voice/realtime.ts | 11 +------ 3 files changed, 44 insertions(+), 10 deletions(-) create mode 100644 extensions/discord/src/voice/realtime-transcript.test.ts create mode 100644 extensions/discord/src/voice/realtime-transcript.ts diff --git a/extensions/discord/src/voice/realtime-transcript.test.ts b/extensions/discord/src/voice/realtime-transcript.test.ts new file mode 100644 index 000000000000..f1c46e6242c9 --- /dev/null +++ b/extensions/discord/src/voice/realtime-transcript.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { mergeRealtimePartialTranscript } from "./realtime-transcript.js"; + +describe("mergeRealtimePartialTranscript", () => { + it("returns previous transcript when the next chunk is blank", () => { + expect(mergeRealtimePartialTranscript("hello", " ")).toBe("hello"); + }); + + it("replaces with the growing chunk when it extends the previous prefix", () => { + expect(mergeRealtimePartialTranscript("hel", "hello world")).toBe("hello world"); + }); + + it("appends when the next chunk is not a continuation of previous", () => { + expect(mergeRealtimePartialTranscript("hello", " there")).toBe("hello there"); + }); + + it("does not split a surrogate pair at the tail cap boundary", () => { + const tail = "x".repeat(239); + const next = `${"y".repeat(50)}🦞${tail}`; + + expect(mergeRealtimePartialTranscript("", next)).toBe(tail); + }); + + it("keeps an intact surrogate pair that sits just inside the cap", () => { + const tail = `🦞${"w".repeat(238)}`; + const next = `${"z".repeat(10)}${tail}`; + + expect(mergeRealtimePartialTranscript("", next)).toBe(tail); + }); +}); diff --git a/extensions/discord/src/voice/realtime-transcript.ts b/extensions/discord/src/voice/realtime-transcript.ts new file mode 100644 index 000000000000..ba5a4773aeca --- /dev/null +++ b/extensions/discord/src/voice/realtime-transcript.ts @@ -0,0 +1,13 @@ +// Discord plugin module owns realtime transcript accumulation. +import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; + +const PARTIAL_TRANSCRIPT_MAX_CHARS = 240; + +export function mergeRealtimePartialTranscript(previous: string, next: string): string { + const trimmed = next.trim(); + if (!trimmed) { + return previous; + } + const merged = trimmed.startsWith(previous) ? trimmed : `${previous}${next}`; + return sliceUtf16Safe(merged, -PARTIAL_TRANSCRIPT_MAX_CHARS); +} diff --git a/extensions/discord/src/voice/realtime.ts b/extensions/discord/src/voice/realtime.ts index 8986038b6ac0..91922e904b46 100644 --- a/extensions/discord/src/voice/realtime.ts +++ b/extensions/discord/src/voice/realtime.ts @@ -53,6 +53,7 @@ import { } from "./audio.js"; import { formatVoiceLogPreview } from "./log-preview.js"; import { formatVoiceIngressPrompt } from "./prompt.js"; +import { mergeRealtimePartialTranscript } from "./realtime-transcript.js"; import { loadDiscordVoiceSdk } from "./sdk-runtime.js"; import { logVoiceVerbose, @@ -88,7 +89,6 @@ const DISCORD_REALTIME_DUPLICATE_ERROR_SUPPRESS_MS = 60_000; const DISCORD_REALTIME_CONTROL_SPEECH_DEDUPE_MS = 5_000; const DISCORD_REALTIME_OUTPUT_PLAYBACK_WATCHDOG_MARGIN_MS = 1_500; const DISCORD_REALTIME_WAKE_ACKS = ["Yeah.", "Mm-hmm.", "Got it.", "One sec."]; -const DISCORD_REALTIME_PARTIAL_TRANSCRIPT_MAX_CHARS = 240; const REALTIME_PCM16_BYTES_PER_SAMPLE = 2; const DISCORD_RAW_PCM_FRAME_BYTES = 3_840; const DISCORD_REALTIME_OUTPUT_PREROLL_FRAMES = 25; @@ -314,15 +314,6 @@ function normalizeControlSpeechText(text: string): string { return text.toLowerCase().replace(/\s+/g, " ").trim(); } -function mergeRealtimePartialTranscript(previous: string, next: string): string { - const trimmed = next.trim(); - if (!trimmed) { - return previous; - } - const merged = trimmed.startsWith(previous) ? trimmed : `${previous}${next}`; - return merged.slice(-DISCORD_REALTIME_PARTIAL_TRANSCRIPT_MAX_CHARS); -} - function resolveDiscordRealtimeWakeNames(params: { config: DiscordRealtimeVoiceConfig; cfg: OpenClawConfig;