@@ -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 {
353354 return normalized || undefined ;
354355}
355356
357+ function normalizeWakeNameCandidate ( value : string ) : string | undefined {
358+ const normalized = value
359+ . toLowerCase ( )
360+ . replace ( / [ ^ a - z 0 - 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 - z 0 - 9 ] + / g, "" ) ;
368+ }
369+
356370function escapeRegExp ( value : string ) : string {
357371 return value . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ;
358372}
@@ -384,6 +398,136 @@ function stripLeadingWakeName(text: string, wakeName: string): string {
384398 . trim ( ) ;
385399}
386400
401+ 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 * (?: (?: h e y | o k | o k a y ) (?: \s * [ - , : ; ] + \s * | \s + ) ) ? / i. exec ( text ) ;
414+ const nameStart = opener ?. [ 0 ] . length ?? 0 ;
415+ const candidates : LeadingWakeNameCandidate [ ] = [ ] ;
416+ const tokenPattern = / [ a - z 0 - 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 : {
388532 config : DiscordRealtimeVoiceConfig ;
389533 cfg : OpenClawConfig ;
@@ -1273,13 +1417,26 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
12731417 this . talkback . enqueue ( acceptedText , this . consumePendingSpeakerContext ( ) ) ;
12741418 }
12751419
1276- private resolveWakeNameTranscript ( text : string ) : { allowed : boolean ; text : string } {
1420+ private resolveWakeNameTranscript ( text : string ) : WakeNameTranscriptResult {
12771421 if ( ! this . requireWakeName ) {
1278- return { allowed : true , text } ;
1422+ return { allowed : true , text, wakeName : "" , heardName : "" , match : "exact" } ;
12791423 }
12801424 const wakeName = this . wakeNames . find ( ( name ) => includesWakeName ( text , name ) ) ;
12811425 if ( 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 }
12841441 return { allowed : false , text } ;
12851442 }
0 commit comments