Skip to content

Commit a4f1269

Browse files
authored
refactor: share realtime output activity tracking (#86661)
1 parent acbdb8c commit a4f1269

8 files changed

Lines changed: 236 additions & 58 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
77
### Changes
88

99
- Voice: expose shared realtime turn-context tracking through the realtime voice SDK and reuse it for Discord speaker attribution and wake-name context recovery.
10+
- Voice: expose shared realtime output activity tracking through the realtime voice SDK and reuse it for Discord playback activity and barge-in decisions.
1011
- Voice: expose shared realtime consult question matching, speakable-result extraction, and alias-aware forced-consult coordination through the realtime voice SDK, then reuse it in Gateway Talk, Voice Call, and Discord voice paths.
1112
- Voice: share activation-name matching and consult-transcript screening through the realtime voice SDK so Discord, browser voice, and meeting surfaces can reuse one implementation.
1213
- Cron: default `cron.maxConcurrentRuns` to 8 so scheduled automations and their isolated agent turns can make progress in parallel without explicit configuration.
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
5f463f907553c514daf7a7c2d09df88bcfcc73383e8ff41ca4bf10978f303b68 plugin-sdk-api-baseline.json
2-
bc391fa6f4a0bc0cdf157bd165013ca97fce82b057f0a3a7d799b08af10bfc9c plugin-sdk-api-baseline.jsonl
1+
8712c831d993980a72d7a628cd235654a5e7dcf74edda0c9f0c9c9c5ba250afc plugin-sdk-api-baseline.json
2+
f6bf4178e5429f64943f5404f3085c360a7f390c15d15b5b9c23239c7a134ca6 plugin-sdk-api-baseline.jsonl

docs/plugins/sdk-migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -626,7 +626,7 @@ releases.
626626
| `plugin-sdk/speech` | Speech helpers | Speech provider types plus provider-facing directive, registry, validation helpers, and OpenAI-compatible TTS builder |
627627
| `plugin-sdk/speech-core` | Shared speech core | Speech provider types, registry, directives, normalization |
628628
| `plugin-sdk/realtime-transcription` | Realtime transcription helpers | Provider types, registry helpers, and shared WebSocket session helper |
629-
| `plugin-sdk/realtime-voice` | Realtime voice helpers | Provider types, registry/resolution helpers, bridge session helpers, shared agent talk-back queues, active-run voice control, transcript/event health, echo suppression, consult question matching, forced-consult coordination, turn-context tracking, and fast context consult helpers |
629+
| `plugin-sdk/realtime-voice` | Realtime voice helpers | Provider types, registry/resolution helpers, bridge session helpers, shared agent talk-back queues, active-run voice control, transcript/event health, echo suppression, consult question matching, forced-consult coordination, turn-context tracking, output activity tracking, and fast context consult helpers |
630630
| `plugin-sdk/image-generation` | Image-generation helpers | Image generation provider types plus image asset/data URL helpers and the OpenAI-compatible image provider builder |
631631
| `plugin-sdk/image-generation-core` | Shared image-generation core | Image-generation types, failover, auth, and registry helpers |
632632
| `plugin-sdk/music-generation` | Music-generation helpers | Music-generation provider/request/result types |

docs/plugins/sdk-subpaths.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,7 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`,
331331
| `plugin-sdk/speech-core` | Shared speech provider types, registry, directive, normalization, and speech helper exports |
332332
| `plugin-sdk/realtime-transcription` | Realtime transcription provider types, registry helpers, and shared WebSocket session helper |
333333
| `plugin-sdk/realtime-bootstrap-context` | Realtime profile bootstrap helper for bounded `IDENTITY.md`, `USER.md`, and `SOUL.md` context injection |
334-
| `plugin-sdk/realtime-voice` | Realtime voice provider types, registry helpers, and shared realtime voice behavior helpers |
334+
| `plugin-sdk/realtime-voice` | Realtime voice provider types, registry helpers, and shared realtime voice behavior helpers, including output activity tracking |
335335
| `plugin-sdk/image-generation` | Image generation provider types plus image asset/data URL helpers and the OpenAI-compatible image provider builder |
336336
| `plugin-sdk/image-generation-core` | Shared image-generation types, failover, auth, and registry helpers |
337337
| `plugin-sdk/music-generation` | Music generation provider/request/result types |

extensions/discord/src/voice/realtime.ts

Lines changed: 53 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
createRealtimeVoiceAgentTalkbackQueue,
99
createRealtimeVoiceBridgeSession,
1010
createRealtimeVoiceForcedConsultCoordinator,
11+
createRealtimeVoiceOutputActivityTracker,
1112
createRealtimeVoiceTurnContextTracker,
1213
matchRealtimeVoiceActivationName,
1314
matchRealtimeVoiceConsultQuestions,
@@ -30,6 +31,7 @@ import {
3031
type RealtimeVoiceToolCallEvent,
3132
type RealtimeVoiceForcedConsultCoordinator,
3233
type RealtimeVoiceForcedConsultHandle,
34+
type RealtimeVoiceOutputActivityTracker,
3335
type RealtimeVoiceTurnContextHandle,
3436
type RealtimeVoiceTurnContextTracker,
3537
sortRealtimeVoiceActivationNames,
@@ -376,15 +378,10 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
376378
ignoredContextTtlMs: DISCORD_REALTIME_IGNORED_WAKE_NAME_CONTEXT_TTL_MS,
377379
},
378380
);
379-
private outputAudioTimestampMs = 0;
380-
private outputAudioDiscordBytes = 0;
381-
private outputAudioRealtimeBytes = 0;
382-
private outputAudioChunks = 0;
383-
private outputAudioStartedAt: number | undefined;
381+
private readonly outputActivity: RealtimeVoiceOutputActivityTracker =
382+
createRealtimeVoiceOutputActivityTracker();
384383
private outputPlaybackWatchdog: ReturnType<typeof setTimeout> | undefined;
385-
private outputStreamEnding = false;
386384
private outputPacedBuffer: Buffer = Buffer.alloc(0);
387-
private outputPlaybackStarted = false;
388385
private realtimeProviderId: string | undefined;
389386
private queuedExactSpeechMessages: string[] = [];
390387
private exactSpeechResponseActive = false;
@@ -653,7 +650,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
653650
turn.inputChunks += 1;
654651
if (turn.inputChunks === 1) {
655652
logger.info(
656-
`discord voice: realtime input audio started guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} user=${turn.context.userId} speaker=${turn.context.speakerLabel} discordBytes=${discordPcm48kStereo.length} realtimeBytes=${realtimePcm.length} outputAudioMs=${Math.floor(this.outputAudioTimestampMs)} outputActive=${this.isOutputAudioActive()}`,
653+
`discord voice: realtime input audio started guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} user=${turn.context.userId} speaker=${turn.context.speakerLabel} discordBytes=${discordPcm48kStereo.length} realtimeBytes=${realtimePcm.length} outputAudioMs=${this.outputAudioMs()} outputActive=${this.isOutputAudioActive()}`,
657654
);
658655
}
659656
const outputActive = this.hasInterruptibleOutputAudio();
@@ -663,7 +660,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
663660
`realtime barge-in from active speaker audio: guild ${this.params.entry.guildId} channel ${this.params.entry.channelId} user ${turn.context.userId}`,
664661
);
665662
logger.info(
666-
`discord voice: realtime barge-in detected source=active-speaker-audio guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} user=${turn.context.userId} speaker=${turn.context.speakerLabel} outputAudioMs=${Math.floor(this.outputAudioTimestampMs)} outputActive=${this.isOutputAudioActive()} discordBytes=${discordPcm48kStereo.length} realtimeBytes=${realtimePcm.length}`,
663+
`discord voice: realtime barge-in detected source=active-speaker-audio guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} user=${turn.context.userId} speaker=${turn.context.speakerLabel} outputAudioMs=${this.outputAudioMs()} outputActive=${this.isOutputAudioActive()} discordBytes=${discordPcm48kStereo.length} realtimeBytes=${realtimePcm.length}`,
667664
);
668665
this.handleBargeIn("active-speaker-audio");
669666
}
@@ -681,12 +678,12 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
681678
const outputActive = this.hasInterruptibleOutputAudio();
682679
if (!outputActive) {
683680
logger.info(
684-
`discord voice: realtime barge-in ignored reason=${reason} outputActive=false guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} playbackChunks=${this.outputAudioChunks}`,
681+
`discord voice: realtime barge-in ignored reason=${reason} outputActive=false guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} playbackChunks=${this.outputAudioChunks()}`,
685682
);
686683
return;
687684
}
688685
logger.info(
689-
`discord voice: realtime barge-in requested reason=${reason} guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} outputAudioMs=${Math.floor(this.outputAudioTimestampMs)} outputActive=${this.isOutputAudioActive()} playbackChunks=${this.outputAudioChunks}`,
686+
`discord voice: realtime barge-in requested reason=${reason} guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} outputAudioMs=${this.outputAudioMs()} outputActive=${this.isOutputAudioActive()} playbackChunks=${this.outputAudioChunks()}`,
690687
);
691688
this.bridge?.handleBargeIn({ audioPlaybackActive: true });
692689
}
@@ -704,9 +701,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
704701

705702
private hasInterruptibleOutputAudio(): boolean {
706703
this.syncOutputAudioTimestamp();
707-
return (
708-
this.isOutputAudioActive() || this.outputAudioChunks > 0 || this.outputAudioTimestampMs > 0
709-
);
704+
return this.outputActivity.isInterruptible(this.isOutputStreamActive());
710705
}
711706

712707
private get realtimeConfig(): DiscordRealtimeVoiceConfig {
@@ -719,7 +714,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
719714
return;
720715
}
721716
this.syncOutputAudioTimestamp();
722-
if (this.outputStreamEnding) {
717+
if (this.outputActivity.snapshot().streamEnding) {
723718
logVoiceVerbose(
724719
`realtime output audio ignored after stream ending: guild ${this.params.entry.guildId} channel ${this.params.entry.channelId}`,
725720
);
@@ -729,13 +724,14 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
729724
if (this.exactSpeechResponseActive) {
730725
this.exactSpeechAudioStarted = true;
731726
}
732-
this.outputAudioDiscordBytes += discordPcm.length;
733-
this.outputAudioRealtimeBytes += realtimePcm24kMono.length;
734-
this.outputAudioChunks += 1;
735-
this.outputAudioTimestampMs += pcm16MonoDurationMs(
736-
realtimePcm24kMono,
737-
REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ.sampleRateHz,
738-
);
727+
this.outputActivity.markAudio({
728+
audioMs: pcm16MonoDurationMs(
729+
realtimePcm24kMono,
730+
REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ.sampleRateHz,
731+
),
732+
sourceAudioBytes: realtimePcm24kMono.length,
733+
sinkAudioBytes: discordPcm.length,
734+
});
739735
this.queueOutputAudio(stream, discordPcm);
740736
}
741737

@@ -746,7 +742,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
746742
const stream = new PassThrough({ highWaterMark: DISCORD_RAW_PCM_FRAME_BYTES * 128 });
747743
this.outputStream = stream;
748744
this.outputPacedBuffer = Buffer.alloc(0);
749-
this.outputPlaybackStarted = false;
745+
this.outputActivity.markStreamOpened();
750746
stream.once("close", () => {
751747
if (this.outputStream === stream) {
752748
this.logOutputAudioStopped("stream-close");
@@ -759,7 +755,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
759755
}
760756

761757
private queueOutputAudio(stream: PassThrough, discordPcm: Buffer): void {
762-
if (this.outputPlaybackStarted) {
758+
if (this.outputActivity.snapshot().playbackStarted) {
763759
stream.write(discordPcm);
764760
return;
765761
}
@@ -776,7 +772,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
776772
}
777773

778774
private startOutputPlayback(stream: PassThrough): void {
779-
if (this.outputPlaybackStarted || stream.destroyed) {
775+
if (this.outputActivity.snapshot().playbackStarted || stream.destroyed) {
780776
return;
781777
}
782778
const voiceSdk = loadDiscordVoiceSdk();
@@ -788,8 +784,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
788784
inputType: voiceSdk.StreamType.Raw,
789785
});
790786
this.params.entry.player.play(resource);
791-
this.outputPlaybackStarted = true;
792-
this.outputAudioStartedAt = Date.now();
787+
this.outputActivity.markPlaybackStarted();
793788
const realtimeConfig = this.realtimeConfig;
794789
logger.info(
795790
`discord voice: realtime audio playback started guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} mode=${this.params.mode} model=${realtimeConfig?.model ?? "provider-default"} voice=${realtimeConfig?.voice ?? "provider-default"}`,
@@ -807,7 +802,6 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
807802
this.logOutputAudioStopped(reason);
808803
this.outputStream = null;
809804
this.outputPacedBuffer = Buffer.alloc(0);
810-
this.outputPlaybackStarted = false;
811805
this.resetOutputAudioStats();
812806
stream?.end();
813807
stream?.destroy();
@@ -818,12 +812,12 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
818812
{ playBuffered = true }: { playBuffered?: boolean } = {},
819813
): void {
820814
const stream = this.outputStream;
821-
if (!stream || stream.destroyed || this.outputStreamEnding) {
815+
if (!stream || stream.destroyed || this.outputActivity.snapshot().streamEnding) {
822816
return;
823817
}
824-
this.outputStreamEnding = true;
818+
this.outputActivity.markStreamEnding();
825819
logger.info(
826-
`discord voice: realtime audio playback finishing reason=${reason} guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} audioMs=${Math.floor(this.outputAudioTimestampMs)} chunks=${this.outputAudioChunks}`,
820+
`discord voice: realtime audio playback finishing reason=${reason} guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} audioMs=${this.outputAudioMs()} chunks=${this.outputAudioChunks()}`,
827821
);
828822
if (playBuffered) {
829823
this.startOutputPlayback(stream);
@@ -839,14 +833,12 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
839833

840834
private scheduleOutputPlaybackWatchdog(reason: string, stream: PassThrough): void {
841835
this.clearOutputPlaybackWatchdog();
842-
if (!this.outputAudioStartedAt || this.outputAudioTimestampMs <= 0) {
836+
const timeoutMs = this.outputActivity.playbackWatchdogDelayMs({
837+
marginMs: DISCORD_REALTIME_OUTPUT_PLAYBACK_WATCHDOG_MARGIN_MS,
838+
});
839+
if (timeoutMs === undefined) {
843840
return;
844841
}
845-
const elapsedMs = Date.now() - this.outputAudioStartedAt;
846-
const timeoutMs = Math.max(
847-
1_000,
848-
this.outputAudioTimestampMs - elapsedMs + DISCORD_REALTIME_OUTPUT_PLAYBACK_WATCHDOG_MARGIN_MS,
849-
);
850842
this.outputPlaybackWatchdog = setTimeout(() => {
851843
this.outputPlaybackWatchdog = undefined;
852844
if (this.outputStream && this.outputStream !== stream) {
@@ -857,7 +849,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
857849
return;
858850
}
859851
logger.warn(
860-
`discord voice: realtime audio playback watchdog fired reason=${reason} guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} audioMs=${Math.floor(this.outputAudioTimestampMs)} elapsedMs=${this.outputAudioStartedAt ? Date.now() - this.outputAudioStartedAt : 0}`,
852+
`discord voice: realtime audio playback watchdog fired reason=${reason} guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} audioMs=${this.outputAudioMs()} elapsedMs=${this.outputActivity.elapsedPlaybackMs()}`,
861853
);
862854
this.clearOutputAudio("playback-watchdog");
863855
this.completeExactSpeechResponse("playback-watchdog");
@@ -879,7 +871,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
879871
if (this.exactSpeechResponseActive || this.hasInterruptibleOutputAudio()) {
880872
this.queuedExactSpeechMessages.push(text);
881873
logger.info(
882-
`discord voice: realtime exact speech queued guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} queued=${this.queuedExactSpeechMessages.length} outputAudioMs=${Math.floor(this.outputAudioTimestampMs)} outputActive=${this.isOutputAudioActive()}`,
874+
`discord voice: realtime exact speech queued guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} queued=${this.queuedExactSpeechMessages.length} outputAudioMs=${this.outputAudioMs()} outputActive=${this.isOutputAudioActive()}`,
883875
);
884876
return;
885877
}
@@ -986,11 +978,12 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
986978
}
987979

988980
private logOutputAudioStopped(reason: string): void {
989-
const audioMs = Math.floor(this.outputAudioTimestampMs);
990-
const chunks = this.outputAudioChunks;
991-
const discordBytes = this.outputAudioDiscordBytes;
992-
const realtimeBytes = this.outputAudioRealtimeBytes;
993-
const elapsedMs = this.outputAudioStartedAt ? Date.now() - this.outputAudioStartedAt : 0;
981+
const activity = this.outputActivity.snapshot();
982+
const audioMs = Math.floor(activity.audioMs);
983+
const chunks = activity.chunks;
984+
const discordBytes = activity.sinkAudioBytes;
985+
const realtimeBytes = activity.sourceAudioBytes;
986+
const elapsedMs = this.outputActivity.elapsedPlaybackMs();
994987
if (this.outputStream || chunks > 0 || audioMs > 0) {
995988
logger.info(
996989
`discord voice: realtime audio playback stopped reason=${reason} guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} audioMs=${audioMs} elapsedMs=${elapsedMs} chunks=${chunks} discordBytes=${discordBytes} realtimeBytes=${realtimeBytes}`,
@@ -999,22 +992,28 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
999992
}
1000993

1001994
private resetOutputAudioStats(): void {
1002-
this.outputAudioTimestampMs = 0;
1003-
this.outputAudioDiscordBytes = 0;
1004-
this.outputAudioRealtimeBytes = 0;
1005-
this.outputAudioChunks = 0;
1006-
this.outputAudioStartedAt = undefined;
1007-
this.outputStreamEnding = false;
1008995
this.outputPacedBuffer = Buffer.alloc(0);
1009-
this.outputPlaybackStarted = false;
996+
this.outputActivity.reset();
1010997
}
1011998

1012999
private syncOutputAudioTimestamp(): void {
1013-
this.bridge?.setMediaTimestamp(Math.floor(this.outputAudioTimestampMs));
1000+
this.bridge?.setMediaTimestamp(this.outputAudioMs());
1001+
}
1002+
1003+
private outputAudioMs(): number {
1004+
return Math.floor(this.outputActivity.snapshot().audioMs);
1005+
}
1006+
1007+
private outputAudioChunks(): number {
1008+
return this.outputActivity.snapshot().chunks;
1009+
}
1010+
1011+
private isOutputStreamActive(): boolean {
1012+
return Boolean(this.outputStream && !this.outputStream.destroyed);
10141013
}
10151014

10161015
private isOutputAudioActive(): boolean {
1017-
return Boolean(this.outputStream && !this.outputStream.destroyed) || this.outputAudioChunks > 0;
1016+
return this.outputActivity.isActive(this.isOutputStreamActive());
10181017
}
10191018

10201019
private logSpeakerTurnClosed(turn: PendingSpeakerTurn): void {
@@ -1385,7 +1384,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
13851384
);
13861385
if (this.hasInterruptibleOutputAudio()) {
13871386
logger.info(
1388-
`discord voice: realtime forced agent consult preserving active playback guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} outputAudioMs=${Math.floor(this.outputAudioTimestampMs)} outputActive=${this.isOutputAudioActive()} playbackChunks=${this.outputAudioChunks}`,
1387+
`discord voice: realtime forced agent consult preserving active playback guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} outputAudioMs=${this.outputAudioMs()} outputActive=${this.isOutputAudioActive()} playbackChunks=${this.outputAudioChunks()}`,
13891388
);
13901389
}
13911390
state.handledByForcedPlayback = true;

src/plugin-sdk/realtime-voice.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@ export {
9090
type RealtimeVoiceTurnContextTracker,
9191
type RealtimeVoiceTurnContextTrackerOptions,
9292
} from "../talk/turn-context-tracker.js";
93+
export {
94+
createRealtimeVoiceOutputActivityTracker,
95+
type RealtimeVoiceOutputActivityDelta,
96+
type RealtimeVoiceOutputActivitySnapshot,
97+
type RealtimeVoiceOutputActivityTracker,
98+
type RealtimeVoiceOutputActivityTrackerOptions,
99+
} from "../talk/output-activity-tracker.js";
93100
export {
94101
buildRealtimeVoiceAgentConsultChatMessage,
95102
buildRealtimeVoiceAgentConsultPolicyInstructions,

0 commit comments

Comments
 (0)