

























@@ -71,6 +71,8 @@ const DISCORD_REALTIME_FORCED_CONSULT_FALLBACK_DELAY_MS = 200;
7171const DISCORD_REALTIME_DUPLICATE_ERROR_SUPPRESS_MS = 60_000;
7272const DISCORD_REALTIME_CONTROL_SPEECH_DEDUPE_MS = 5_000;
7373const DISCORD_REALTIME_OUTPUT_PLAYBACK_WATCHDOG_MARGIN_MS = 1_500;
74+const DISCORD_REALTIME_WAKE_ACKS = ["Yeah.", "Mm-hmm.", "Got it.", "One sec."];
75+const DISCORD_REALTIME_PARTIAL_TRANSCRIPT_MAX_CHARS = 240;
7476const REALTIME_PCM16_BYTES_PER_SAMPLE = 2;
7577const DISCORD_RAW_PCM_FRAME_BYTES = 3_840;
7678const DISCORD_REALTIME_OUTPUT_PREROLL_FRAMES = 25;
@@ -314,6 +316,15 @@ function normalizeControlSpeechText(text: string): string {
314316return text.toLowerCase().replace(/\s+/g, " ").trim();
315317}
316318319+function mergeRealtimePartialTranscript(previous: string, next: string): string {
320+const trimmed = next.trim();
321+if (!trimmed) {
322+return previous;
323+}
324+const merged = trimmed.startsWith(previous) ? trimmed : `${previous}${next}`;
325+return merged.slice(-DISCORD_REALTIME_PARTIAL_TRANSCRIPT_MAX_CHARS);
326+}
327+317328function resolveDiscordRealtimeWakeNames(params: {
318329config: DiscordRealtimeVoiceConfig;
319330cfg: OpenClawConfig;
@@ -380,6 +391,9 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
380391private queuedExactSpeechMessages: string[] = [];
381392private exactSpeechResponseActive = false;
382393private exactSpeechAudioStarted = false;
394+private partialUserTranscript = "";
395+private wakeNameAckedForTurn = false;
396+private wakeNameAckIndex = 0;
383397private lastControlSpeech:
384398| { normalizedText: string; sentAt: number; assistantTranscriptCount: number }
385399| undefined;
@@ -499,14 +513,21 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
499513if (isFinal && role === "assistant") {
500514this.suppressDuplicateControlSpeech(text);
501515}
502-if (!isFinal || role !== "user") {
516+if (role !== "user") {
517+return;
518+}
519+if (!isFinal) {
520+this.handlePartialUserTranscript(text);
503521return;
504522}
505523void this.handleFinalUserTranscript(text, { usesRealtimeAgentHandoff });
506524},
507525onToolCall: (event, session) => this.handleToolCall(event, session),
508526onEvent: (event) => {
509527const detail = event.detail ? ` ${event.detail}` : "";
528+if (event.direction === "server" && event.type === "input_audio_buffer.speech_started") {
529+this.resetPartialWakeNameTracking();
530+}
510531if (shouldLogRealtimeVerboseEvent(event)) {
511532logVoiceVerbose(`realtime ${event.direction}:${event.type}${detail}`);
512533}
@@ -567,6 +588,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
567588this.queuedExactSpeechMessages = [];
568589this.exactSpeechResponseActive = false;
569590this.exactSpeechAudioStarted = false;
591+this.resetPartialWakeNameTracking();
570592this.clearOutputAudio("session-close");
571593this.bridge?.close();
572594this.bridge = null;
@@ -600,6 +622,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
600622}
601623602624beginSpeakerTurn(context: VoiceRealtimeSpeakerContext, userId: string): VoiceRealtimeSpeakerTurn {
625+this.resetPartialWakeNameTracking();
603626const turn: PendingSpeakerTurn = {
604627context: { ...context, userId },
605628hasAudio: false,
@@ -882,6 +905,25 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
882905this.bridge?.sendUserMessage(buildDiscordSpeakExactUserMessage(text));
883906}
884907908+private sendWakeNameAck(result: RealtimeVoiceActivationNameTranscriptResult): void {
909+if (!result.allowed || this.stopped || this.exactSpeechResponseActive) {
910+return;
911+}
912+if (this.hasInterruptibleOutputAudio()) {
913+logger.info(
914+`discord voice: realtime wake-name ack skipped outputActive=true voiceSession=${this.params.entry.voiceSessionKey} agent=${this.params.entry.route.agentId}`,
915+);
916+return;
917+}
918+const ack =
919+DISCORD_REALTIME_WAKE_ACKS[this.wakeNameAckIndex % DISCORD_REALTIME_WAKE_ACKS.length];
920+this.wakeNameAckIndex += 1;
921+logger.info(
922+`discord voice: realtime wake-name ack canonical=${result.activationName} heard=${result.heardName} match=${result.match} voiceSession=${this.params.entry.voiceSessionKey} agent=${this.params.entry.route.agentId}`,
923+);
924+this.sendExactSpeechMessage(ack ?? "Yeah.");
925+}
926+885927private speakControlResult(text: string): void {
886928const trimmed = text.trim();
887929if (this.stopped || !trimmed) {
@@ -1151,6 +1193,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
11511193if (!trimmed) {
11521194return;
11531195}
1196+this.partialUserTranscript = "";
11541197const meetingNotesTurn = this.peekPendingSpeakerTurn();
11551198this.recordMeetingNotesUtterance(trimmed, meetingNotesTurn);
11561199const wakeNameResult = this.resolveWakeNameTranscript(trimmed);
@@ -1200,6 +1243,27 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
12001243this.talkback.enqueue(acceptedText, this.consumePendingSpeakerContext());
12011244}
120212451246+private handlePartialUserTranscript(text: string): void {
1247+if (!this.requireWakeName || this.wakeNameAckedForTurn) {
1248+return;
1249+}
1250+this.partialUserTranscript = mergeRealtimePartialTranscript(this.partialUserTranscript, text);
1251+const wakeNameResult = matchRealtimeVoiceActivationName(
1252+this.partialUserTranscript,
1253+this.wakeNames,
1254+);
1255+if (!wakeNameResult || wakeNameResult.edge !== "leading") {
1256+return;
1257+}
1258+this.wakeNameAckedForTurn = true;
1259+this.sendWakeNameAck(wakeNameResult);
1260+}
1261+1262+private resetPartialWakeNameTracking(): void {
1263+this.partialUserTranscript = "";
1264+this.wakeNameAckedForTurn = false;
1265+}
1266+12031267private resolveWakeNameTranscript(text: string): RealtimeVoiceActivationNameTranscriptResult {
12041268if (!this.requireWakeName) {
12051269return {
@@ -1672,6 +1736,7 @@ function buildDiscordRealtimeInstructions(params: {
16721736"Delegate substantive requests, actions, tool work, current facts, memory, workspace context, and user-specific context with openclaw_agent_consult.",
16731737"Do not block, refuse, or downscope at the voice layer. Delegate to OpenClaw and treat its result as authoritative.",
16741738"Answer directly only for greetings, acknowledgements, brief latency tests, or filler while waiting.",
1739+'While waiting for OpenClaw data or tool results, use at most one short natural backchannel such as "yeah", "mm-hmm", "got it", or "one sec"; vary it and do not treat it as the final answer.',
16751740"When OpenClaw sends an internal exact answer to speak, do not call tools. Say only that answer.",
16761741buildRealtimeVoiceAgentConsultPolicyInstructions({
16771742toolPolicy: params.toolPolicy,
@@ -1682,6 +1747,7 @@ function buildDiscordRealtimeInstructions(params: {
16821747return [
16831748base,
16841749params.bootstrapContextInstructions?.trim(),
1750+'While waiting for OpenClaw data or tool results, use at most one short natural backchannel such as "yeah", "mm-hmm", "got it", or "one sec"; vary it and do not treat it as the final answer.',
16851751buildRealtimeVoiceAgentConsultPolicyInstructions({
16861752toolPolicy: params.toolPolicy,
16871753consultPolicy: params.consultPolicy,
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。