
























@@ -7,6 +7,7 @@ import {
77controlRealtimeVoiceAgentRun,
88createRealtimeVoiceAgentTalkbackQueue,
99createRealtimeVoiceBridgeSession,
10+createRealtimeVoiceTurnContextTracker,
1011matchRealtimeVoiceActivationName,
1112matchRealtimeVoiceConsultQuestions,
1213normalizeSupportedRealtimeVoiceActivationName,
@@ -26,6 +27,8 @@ import {
2627type RealtimeVoiceBridgeSession,
2728type RealtimeVoiceProviderConfig,
2829type RealtimeVoiceToolCallEvent,
30+type RealtimeVoiceTurnContextHandle,
31+type RealtimeVoiceTurnContextTracker,
2932sortRealtimeVoiceActivationNames,
3033type RealtimeVoiceActivationNameTranscriptResult,
3134} from "openclaw/plugin-sdk/realtime-voice";
@@ -94,18 +97,18 @@ type DiscordRealtimeSpeakerContext = VoiceRealtimeSpeakerContext & { userId: str
94979598type DiscordRealtimeVoiceConfig = NonNullable<DiscordAccountConfig["voice"]>["realtime"];
969997-type PendingSpeakerTurn = {
98-context: DiscordRealtimeSpeakerContext;
99-hasAudio: boolean;
100+type PendingSpeakerTurnStats = {
100101inputDiscordBytes: number;
101102inputRealtimeBytes: number;
102103inputChunks: number;
103104interruptedPlayback: boolean;
104-closed: boolean;
105-startedAt: number;
106-lastAudioAt?: number;
107105};
108106107+type PendingSpeakerTurn = RealtimeVoiceTurnContextHandle<
108+DiscordRealtimeSpeakerContext,
109+PendingSpeakerTurnStats
110+>;
111+109112type PendingAgentProxyConsultContext = {
110113context: DiscordRealtimeSpeakerContext;
111114question: string;
@@ -126,11 +129,6 @@ type RecentAgentProxyConsultContext = {
126129result?: RecentAgentProxyConsultResult;
127130};
128131129-type RecentIgnoredWakeNameSpeakerContext = {
130-context: DiscordRealtimeSpeakerContext;
131-createdAt: number;
132-};
133-134132function formatRealtimeLogPreview(text: string): string {
135133const oneLine = text.replace(/\s+/g, " ").trim();
136134if (oneLine.length <= DISCORD_REALTIME_LOG_PREVIEW_CHARS) {
@@ -366,8 +364,15 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
366364private wakeNames: string[] = [];
367365private pendingAgentProxyConsultContexts: PendingAgentProxyConsultContext[] = [];
368366private recentAgentProxyConsultContexts: RecentAgentProxyConsultContext[] = [];
369-private recentIgnoredWakeNameSpeakerContext: RecentIgnoredWakeNameSpeakerContext | undefined;
370-private readonly pendingSpeakerTurns: PendingSpeakerTurn[] = [];
367+private readonly speakerTurns: RealtimeVoiceTurnContextTracker<
368+DiscordRealtimeSpeakerContext,
369+PendingSpeakerTurnStats
370+> = createRealtimeVoiceTurnContextTracker<DiscordRealtimeSpeakerContext, PendingSpeakerTurnStats>(
371+{
372+limit: DISCORD_REALTIME_PENDING_SPEAKER_CONTEXT_LIMIT,
373+ignoredContextTtlMs: DISCORD_REALTIME_IGNORED_WAKE_NAME_CONTEXT_TTL_MS,
374+},
375+);
371376private outputAudioTimestampMs = 0;
372377private outputAudioDiscordBytes = 0;
373378private outputAudioRealtimeBytes = 0;
@@ -573,8 +578,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
573578this.clearForcedConsultTimers();
574579this.pendingAgentProxyConsultContexts = [];
575580this.recentAgentProxyConsultContexts = [];
576-this.recentIgnoredWakeNameSpeakerContext = undefined;
577-this.pendingSpeakerTurns.length = 0;
581+this.speakerTurns.clear();
578582this.queuedExactSpeechMessages = [];
579583this.exactSpeechResponseActive = false;
580584this.exactSpeechAudioStarted = false;
@@ -613,29 +617,25 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
613617614618beginSpeakerTurn(context: VoiceRealtimeSpeakerContext, userId: string): VoiceRealtimeSpeakerTurn {
615619this.resetPartialWakeNameTracking();
616-const turn: PendingSpeakerTurn = {
617-context: { ...context, userId },
618-hasAudio: false,
619-inputDiscordBytes: 0,
620-inputRealtimeBytes: 0,
621-inputChunks: 0,
622-interruptedPlayback: false,
623-closed: false,
624-startedAt: Date.now(),
625-};
626-this.pendingSpeakerTurns.push(turn);
620+const turn = this.speakerTurns.open(
621+{ ...context, userId },
622+{
623+inputDiscordBytes: 0,
624+inputRealtimeBytes: 0,
625+inputChunks: 0,
626+interruptedPlayback: false,
627+},
628+);
627629logger.info(
628-`discord voice: realtime speaker turn opened guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} user=${userId} speaker=${context.speakerLabel} owner=${context.senderIsOwner} pendingTurns=${this.pendingSpeakerTurns.length}`,
630+`discord voice: realtime speaker turn opened guild=${this.params.entry.guildId} channel=${this.params.entry.channelId} user=${userId} speaker=${context.speakerLabel} owner=${context.senderIsOwner} pendingTurns=${this.speakerTurns.size()}`,
629631);
630-this.prunePendingSpeakerTurns();
631632return {
632633sendInputAudio: (discordPcm48kStereo) =>
633634this.sendInputAudioForTurn(turn, discordPcm48kStereo),
634635close: () => {
635636this.sendRealtimeTrailingSilenceForTurn(turn);
636637this.logSpeakerTurnClosed(turn);
637-turn.closed = true;
638-this.prunePendingSpeakerTurns();
638+this.speakerTurns.close(turn);
639639},
640640};
641641}
@@ -644,13 +644,12 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
644644if (!this.bridge || this.stopped) {
645645return;
646646}
647-turn.hasAudio = true;
647+this.speakerTurns.markAudio(turn);
648648const realtimePcm = convertDiscordPcm48kStereoToRealtimePcm24kMono(discordPcm48kStereo);
649649if (realtimePcm.length > 0) {
650650turn.inputDiscordBytes += discordPcm48kStereo.length;
651651turn.inputRealtimeBytes += realtimePcm.length;
652652turn.inputChunks += 1;
653-turn.lastAudioAt = Date.now();
654653if (turn.inputChunks === 1) {
655654logger.info(
656655`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()}`,
@@ -1456,79 +1455,25 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
14561455}
1457145614581457private consumePendingSpeakerContext(): DiscordRealtimeSpeakerContext | undefined {
1459-this.prunePendingSpeakerTurns();
1460-this.expireClosedSpeakerTurnsBeforeLaterAudio();
1461-const index = this.pendingSpeakerTurns.findIndex((turn) => turn.hasAudio);
1462-if (index < 0) {
1463-return undefined;
1464-}
1465-const [turn] = this.pendingSpeakerTurns.splice(index, 1);
1466-this.prunePendingSpeakerTurns();
1467-return turn?.context;
1458+return this.speakerTurns.consumeAudioContext();
14681459}
1469146014701461private rememberIgnoredWakeNameSpeakerContext(
14711462context: DiscordRealtimeSpeakerContext | undefined,
14721463): void {
1473-if (!context) {
1474-return;
1475-}
1476-this.recentIgnoredWakeNameSpeakerContext = {
1477- context,
1478-createdAt: Date.now(),
1479-};
1464+this.speakerTurns.rememberIgnoredContext(context);
14801465}
1481146614821467private consumeRecentIgnoredWakeNameSpeakerContext(): DiscordRealtimeSpeakerContext | undefined {
1483-const recent = this.recentIgnoredWakeNameSpeakerContext;
1484-this.recentIgnoredWakeNameSpeakerContext = undefined;
1485-if (
1486-!recent ||
1487-Date.now() - recent.createdAt > DISCORD_REALTIME_IGNORED_WAKE_NAME_CONTEXT_TTL_MS
1488-) {
1489-return undefined;
1490-}
1491-return recent.context;
1468+return this.speakerTurns.consumeIgnoredContext();
14921469}
1493147014941471private peekPendingSpeakerTurn(): PendingSpeakerTurn | undefined {
1495-this.prunePendingSpeakerTurns();
1496-this.expireClosedSpeakerTurnsBeforeLaterAudio();
1497-return this.pendingSpeakerTurns.find((turn) => turn.hasAudio);
1472+return this.speakerTurns.peekAudioTurn();
14981473}
1499147415001475private hasPendingSpeakerAudioContext(): boolean {
1501-this.prunePendingSpeakerTurns();
1502-this.expireClosedSpeakerTurnsBeforeLaterAudio();
1503-return this.pendingSpeakerTurns.some((turn) => turn.hasAudio);
1504-}
1505-1506-private prunePendingSpeakerTurns(): void {
1507-for (let index = this.pendingSpeakerTurns.length - 1; index >= 0; index -= 1) {
1508-const turn = this.pendingSpeakerTurns[index];
1509-if (turn?.closed && !turn.hasAudio) {
1510-this.pendingSpeakerTurns.splice(index, 1);
1511-}
1512-}
1513-while (this.pendingSpeakerTurns.length > DISCORD_REALTIME_PENDING_SPEAKER_CONTEXT_LIMIT) {
1514-const completedIndex = this.pendingSpeakerTurns.findIndex((turn) => turn.closed);
1515-this.pendingSpeakerTurns.splice(Math.max(completedIndex, 0), 1);
1516-}
1517-}
1518-1519-private expireClosedSpeakerTurnsBeforeLaterAudio(): void {
1520-let hasLaterAudio = false;
1521-for (let index = this.pendingSpeakerTurns.length - 1; index >= 0; index -= 1) {
1522-const turn = this.pendingSpeakerTurns[index];
1523-if (!turn?.hasAudio) {
1524-continue;
1525-}
1526-if (turn.closed && hasLaterAudio) {
1527-this.pendingSpeakerTurns.splice(index, 1);
1528-continue;
1529-}
1530-hasLaterAudio = true;
1531-}
1476+return this.speakerTurns.hasAudioContext();
15321477}
1533147815341479private rememberRecentAgentProxyConsultContext(
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。