
























@@ -100,6 +100,8 @@ export type GoogleMeetRealtimeEventEntry = RealtimeVoiceBridgeEvent & {
100100};
101101102102export const GOOGLE_MEET_AGENT_TRANSCRIPT_DEBOUNCE_MS = 900;
103+export const GOOGLE_MEET_OUTPUT_ECHO_SUPPRESSION_TAIL_MS = 3_000;
104+export const GOOGLE_MEET_TRANSCRIPT_ECHO_LOOKBACK_MS = 45_000;
103105104106export function recordGoogleMeetRealtimeEvent(
105107events: GoogleMeetRealtimeEventEntry[],
@@ -157,6 +159,80 @@ function readPcm16Stats(audio: Buffer): { rms: number; peak: number } {
157159};
158160}
159161162+function normalizeTranscriptForEchoMatch(text: string): string[] {
163+return text
164+.toLowerCase()
165+.replace(/['’]/g, "")
166+.replace(/[^a-z0-9]+/g, " ")
167+.trim()
168+.split(/\s+/)
169+.filter((token) => token.length > 1);
170+}
171+172+function hasMeaningfulEchoOverlap(userTokens: string[], assistantTokens: string[]): boolean {
173+if (userTokens.length < 4 || assistantTokens.length < 4) {
174+return false;
175+}
176+const assistantTokenSet = new Set(assistantTokens);
177+const overlap = userTokens.filter((token) => assistantTokenSet.has(token)).length;
178+return overlap / userTokens.length >= 0.58;
179+}
180+181+export function isGoogleMeetLikelyAssistantEchoTranscript(params: {
182+transcript: GoogleMeetRealtimeTranscriptEntry[];
183+text: string;
184+nowMs?: number;
185+}): boolean {
186+const userTokens = normalizeTranscriptForEchoMatch(params.text);
187+if (userTokens.length < 4) {
188+return false;
189+}
190+const nowMs = params.nowMs ?? Date.now();
191+const recentAssistantText = params.transcript
192+.filter((entry) => {
193+if (entry.role !== "assistant") {
194+return false;
195+}
196+const at = Date.parse(entry.at);
197+return Number.isFinite(at) && nowMs - at <= GOOGLE_MEET_TRANSCRIPT_ECHO_LOOKBACK_MS;
198+})
199+.slice(-6)
200+.map((entry) => entry.text)
201+.join(" ");
202+if (!recentAssistantText.trim()) {
203+return false;
204+}
205+const userNormalized = userTokens.join(" ");
206+const assistantTokens = normalizeTranscriptForEchoMatch(recentAssistantText);
207+const assistantNormalized = assistantTokens.join(" ");
208+return (
209+(userNormalized.length >= 18 && assistantNormalized.includes(userNormalized)) ||
210+(assistantNormalized.length >= 18 && userNormalized.includes(assistantNormalized)) ||
211+hasMeaningfulEchoOverlap(userTokens, assistantTokens)
212+);
213+}
214+215+export function extendGoogleMeetOutputEchoSuppression(params: {
216+audio: Buffer;
217+audioFormat: GoogleMeetConfig["chrome"]["audioFormat"];
218+nowMs: number;
219+lastOutputPlayableUntilMs: number;
220+suppressInputUntilMs: number;
221+}): { lastOutputPlayableUntilMs: number; suppressInputUntilMs: number; durationMs: number } {
222+const bytesPerMs = params.audioFormat === "g711-ulaw-8khz" ? 8 : 48;
223+const durationMs = Math.ceil(params.audio.byteLength / bytesPerMs);
224+const playbackStartMs = Math.max(params.nowMs, params.lastOutputPlayableUntilMs);
225+const playbackEndMs = playbackStartMs + durationMs;
226+return {
227+ durationMs,
228+lastOutputPlayableUntilMs: playbackEndMs,
229+suppressInputUntilMs: Math.max(
230+params.suppressInputUntilMs,
231+playbackEndMs + GOOGLE_MEET_OUTPUT_ECHO_SUPPRESSION_TAIL_MS,
232+),
233+};
234+}
235+160236export function resolveGoogleMeetRealtimeAudioFormat(config: GoogleMeetConfig) {
161237return config.chrome.audioFormat === "g711-ulaw-8khz"
162238 ? REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ
@@ -227,11 +303,15 @@ export async function startCommandRealtimeAudioBridge(params: {
227303let agentConsultDebounceTimer: ReturnType<typeof setTimeout> | undefined;
228304229305const suppressInputForOutput = (audio: Buffer) => {
230-const bytesPerMs = params.config.chrome.audioFormat === "g711-ulaw-8khz" ? 8 : 48;
231-const durationMs = Math.ceil(audio.byteLength / bytesPerMs);
232-const until = Date.now() + durationMs + 900;
233-suppressInputUntil = Math.max(suppressInputUntil, until);
234-lastOutputPlayableUntilMs = Math.max(lastOutputPlayableUntilMs, until);
306+const suppression = extendGoogleMeetOutputEchoSuppression({
307+ audio,
308+audioFormat: params.config.chrome.audioFormat,
309+nowMs: Date.now(),
310+ lastOutputPlayableUntilMs,
311+suppressInputUntilMs: suppressInputUntil,
312+});
313+suppressInputUntil = suppression.suppressInputUntilMs;
314+lastOutputPlayableUntilMs = suppression.lastOutputPlayableUntilMs;
235315};
236316237317const terminateProcess = (proc: BridgeProcess, signal: NodeJS.Signals = "SIGTERM") => {
@@ -521,6 +601,10 @@ export async function startCommandRealtimeAudioBridge(params: {
521601recordGoogleMeetRealtimeTranscript(transcript, role, text);
522602params.logger.info(`[google-meet] realtime ${role}: ${text}`);
523603if (role === "user" && strategy === "agent") {
604+if (isGoogleMeetLikelyAssistantEchoTranscript({ transcript, text })) {
605+params.logger.info(`[google-meet] realtime ignored assistant echo transcript: ${text}`);
606+return;
607+}
524608enqueueAgentConsultForUserTranscript(text);
525609}
526610}
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。