





















@@ -64,6 +64,7 @@ const DISCORD_REALTIME_FORCED_CONSULT_FALLBACK_DELAY_MS = 200;
6464const DISCORD_REALTIME_DUPLICATE_ERROR_SUPPRESS_MS = 60_000;
6565const DISCORD_REALTIME_CONTROL_SPEECH_DEDUPE_MS = 5_000;
6666const DISCORD_REALTIME_OUTPUT_PLAYBACK_WATCHDOG_MARGIN_MS = 1_500;
67+const DISCORD_REALTIME_WAKE_NAME_FUZZY_PREFIX_WORDS = 3;
6768const REALTIME_PCM16_BYTES_PER_SAMPLE = 2;
6869const DISCORD_RAW_PCM_FRAME_BYTES = 3_840;
6970const DISCORD_REALTIME_OUTPUT_PREROLL_FRAMES = 25;
@@ -353,6 +354,19 @@ function normalizeWakeName(value: string): string | undefined {
353354return normalized || undefined;
354355}
355356357+function normalizeWakeNameCandidate(value: string): string | undefined {
358+const normalized = value
359+.toLowerCase()
360+.replace(/[^a-z0-9]+/g, " ")
361+.replace(/\s+/g, " ")
362+.trim();
363+return normalized || undefined;
364+}
365+366+function compactWakeName(value: string): string {
367+return value.replace(/[^a-z0-9]+/g, "");
368+}
369+356370function escapeRegExp(value: string): string {
357371return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
358372}
@@ -384,6 +398,136 @@ function stripLeadingWakeName(text: string, wakeName: string): string {
384398.trim();
385399}
386400401+type LeadingWakeNameCandidate = {
402+heardName: string;
403+endIndex: number;
404+strongBoundary: boolean;
405+};
406+407+type WakeNameTranscriptResult =
408+| { allowed: true; text: string; wakeName: string; heardName: string; match: "exact" | "fuzzy" }
409+| { allowed: false; text: string };
410+type AllowedWakeNameTranscriptResult = Extract<WakeNameTranscriptResult, { allowed: true }>;
411+412+function leadingWakeNameCandidates(text: string): LeadingWakeNameCandidate[] {
413+const opener = /^\s*(?:(?:hey|ok|okay)(?:\s*[-,:;]+\s*|\s+))?/i.exec(text);
414+const nameStart = opener?.[0].length ?? 0;
415+const candidates: LeadingWakeNameCandidate[] = [];
416+const tokenPattern = /[a-z0-9]+/gi;
417+tokenPattern.lastIndex = nameStart;
418+419+for (
420+let wordCount = 0;
421+wordCount < DISCORD_REALTIME_WAKE_NAME_FUZZY_PREFIX_WORDS;
422+wordCount += 1
423+) {
424+const token = tokenPattern.exec(text);
425+if (!token) {
426+break;
427+}
428+const between = text.slice(
429+wordCount === 0 ? nameStart : candidates[wordCount - 1]?.endIndex,
430+token.index,
431+);
432+if (wordCount > 0 && !/^[\s'-]+$/.test(between)) {
433+break;
434+}
435+const endIndex = token.index + token[0].length;
436+const heardName = normalizeWakeNameCandidate(text.slice(nameStart, endIndex));
437+if (!heardName) {
438+break;
439+}
440+const boundary = text.slice(endIndex).match(/^\s*([,.:;!?-]|$)/);
441+candidates.push({
442+ heardName,
443+ endIndex,
444+strongBoundary: Boolean(boundary),
445+});
446+}
447+448+return candidates;
449+}
450+451+function levenshteinDistance(left: string, right: string): number {
452+if (left === right) {
453+return 0;
454+}
455+if (!left) {
456+return right.length;
457+}
458+if (!right) {
459+return left.length;
460+}
461+462+let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
463+for (let leftIndex = 0; leftIndex < left.length; leftIndex += 1) {
464+const current = [leftIndex + 1];
465+for (let rightIndex = 0; rightIndex < right.length; rightIndex += 1) {
466+const cost = left[leftIndex] === right[rightIndex] ? 0 : 1;
467+current[rightIndex + 1] = Math.min(
468+current[rightIndex] + 1,
469+previous[rightIndex + 1] + 1,
470+previous[rightIndex] + cost,
471+);
472+}
473+previous = current;
474+}
475+return previous[right.length] ?? Math.max(left.length, right.length);
476+}
477+478+function isFuzzyWakeNameMatch(candidate: LeadingWakeNameCandidate, wakeName: string): boolean {
479+const normalizedWakeName = normalizeWakeNameCandidate(wakeName);
480+if (!normalizedWakeName) {
481+return false;
482+}
483+const heardCompact = compactWakeName(candidate.heardName);
484+const wakeCompact = compactWakeName(normalizedWakeName);
485+if (!heardCompact || !wakeCompact || wakeCompact.length < 5) {
486+return false;
487+}
488+if (!candidate.strongBoundary) {
489+return false;
490+}
491+const distance = levenshteinDistance(heardCompact, wakeCompact);
492+if (distance <= 1) {
493+return true;
494+}
495+return distance === 2 && wakeCompact.length >= 5 && heardCompact.length !== wakeCompact.length;
496+}
497+498+function stripLeadingWakeNameCandidate(text: string, candidate: LeadingWakeNameCandidate): string {
499+return text
500+.slice(candidate.endIndex)
501+.replace(/^\s*(?:[-,:;.!?]+\s*)?/, "")
502+.trim();
503+}
504+505+function matchLeadingFuzzyWakeName(
506+text: string,
507+wakeNames: string[],
508+): AllowedWakeNameTranscriptResult | undefined {
509+for (const candidate of leadingWakeNameCandidates(text)) {
510+for (const wakeName of wakeNames) {
511+const normalizedWakeName = normalizeWakeNameCandidate(wakeName);
512+if (!normalizedWakeName) {
513+continue;
514+}
515+const heardCompact = compactWakeName(candidate.heardName);
516+const wakeCompact = compactWakeName(normalizedWakeName);
517+if (heardCompact === wakeCompact || isFuzzyWakeNameMatch(candidate, wakeName)) {
518+return {
519+allowed: true,
520+text: stripLeadingWakeNameCandidate(text, candidate),
521+ wakeName,
522+heardName: candidate.heardName,
523+match: heardCompact === wakeCompact ? "exact" : "fuzzy",
524+};
525+}
526+}
527+}
528+return undefined;
529+}
530+387531function resolveDiscordRealtimeWakeNames(params: {
388532config: DiscordRealtimeVoiceConfig;
389533cfg: OpenClawConfig;
@@ -1273,13 +1417,26 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
12731417this.talkback.enqueue(acceptedText, this.consumePendingSpeakerContext());
12741418}
127514191276-private resolveWakeNameTranscript(text: string): { allowed: boolean; text: string } {
1420+private resolveWakeNameTranscript(text: string): WakeNameTranscriptResult {
12771421if (!this.requireWakeName) {
1278-return { allowed: true, text };
1422+return { allowed: true, text, wakeName: "", heardName: "", match: "exact" };
12791423}
12801424const wakeName = this.wakeNames.find((name) => includesWakeName(text, name));
12811425if (wakeName) {
1282-return { allowed: true, text: stripLeadingWakeName(text, wakeName) };
1426+return {
1427+allowed: true,
1428+text: stripLeadingWakeName(text, wakeName),
1429+ wakeName,
1430+heardName: wakeName,
1431+match: "exact",
1432+};
1433+}
1434+const fuzzyWakeName = matchLeadingFuzzyWakeName(text, this.wakeNames);
1435+if (fuzzyWakeName) {
1436+logger.info(
1437+`discord voice: realtime wake-name gate matched canonical=${fuzzyWakeName.wakeName} heard=${fuzzyWakeName.heardName} match=${fuzzyWakeName.match} voiceSession=${this.params.entry.voiceSessionKey} agent=${this.params.entry.route.agentId}`,
1438+);
1439+return fuzzyWakeName;
12831440}
12841441return { allowed: false, text };
12851442}
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。