Files
openclaw/src/talk/session-runtime.ts
WhatsSkiLL 4d3b18811e feat(android): add Wear real-time Talk controls (#109483)
* feat(android): add Wear real-time Talk controls

* fix(android): satisfy Wear Talk CI checks

* fix(talk): remove unused client language parameter

* fix(android): harden Wear realtime Talk routing

Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>

* chore(android): refresh Wear Talk metadata

* chore(android): align native i18n baseline

---------

Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-17 13:46:05 +01:00

201 lines
6.8 KiB
TypeScript

// Talk session runtime manages realtime voice session lifecycle and provider wiring.
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { RealtimeVoiceProviderPlugin } from "../plugins/types.js";
import type {
RealtimeVoiceBridge,
RealtimeVoiceAudioClearReason,
RealtimeVoiceAudioFormat,
RealtimeVoiceBargeInOptions,
RealtimeVoiceCloseReason,
RealtimeVoiceBridgeEvent,
RealtimeVoiceProviderConfig,
RealtimeVoiceRole,
RealtimeVoiceTool,
RealtimeVoiceToolCallEvent,
RealtimeVoiceToolResultOptions,
} from "./provider-types.js";
/**
* Transport-facing audio target used by realtime voice bridge sessions.
*/
export type RealtimeVoiceAudioSink = {
isOpen?: () => boolean;
sendAudio: (audio: Buffer) => void;
clearAudio?: (reason?: RealtimeVoiceAudioClearReason) => void;
sendMark?: (markName: string) => void;
};
/**
* Controls how provider playback marks are bridged to transports that may or may not ack marks.
*/
export type RealtimeVoiceMarkStrategy = "transport" | "ack-immediately" | "ignore";
/**
* Stable session facade handed to gateway code and provider tool callbacks.
*/
export type RealtimeVoiceBridgeSession = {
bridge: RealtimeVoiceBridge;
acknowledgeMark(markName?: string): void;
close(): void;
connect(): Promise<void>;
sendAudio(audio: Buffer): void;
sendUserMessage(text: string): void;
handleBargeIn(options?: RealtimeVoiceBargeInOptions): void;
setMediaTimestamp(ts: number): void;
submitToolResult(
callId: string,
result: unknown,
options?: RealtimeVoiceToolResultOptions,
): void | Promise<void>;
triggerGreeting(instructions?: string): void;
};
/**
* Provider bridge inputs plus transport callbacks for one realtime voice session.
*/
export type RealtimeVoiceBridgeSessionParams = {
provider: RealtimeVoiceProviderPlugin;
cfg?: OpenClawConfig;
providerConfig: RealtimeVoiceProviderConfig;
audioFormat?: RealtimeVoiceAudioFormat;
audioSink: RealtimeVoiceAudioSink;
instructions?: string;
language?: string;
initialGreetingInstructions?: string;
autoRespondToAudio?: boolean;
interruptResponseOnInputAudio?: boolean;
markStrategy?: RealtimeVoiceMarkStrategy;
triggerGreetingOnReady?: boolean;
tools?: RealtimeVoiceTool[];
onTranscript?: (role: RealtimeVoiceRole, text: string, isFinal: boolean) => void;
onEvent?: (event: RealtimeVoiceBridgeEvent) => void;
onToolCall?: (
event: RealtimeVoiceToolCallEvent,
session: RealtimeVoiceBridgeSession,
) => void | Promise<void>;
onReady?: (session: RealtimeVoiceBridgeSession) => void;
onError?: (error: Error) => void;
onClose?: (reason: RealtimeVoiceCloseReason) => void;
};
/**
* Creates a realtime voice bridge session and wires provider events to the configured audio sink.
*/
export function createRealtimeVoiceBridgeSession(
params: RealtimeVoiceBridgeSessionParams,
): RealtimeVoiceBridgeSession {
const bridgeRef: { current?: RealtimeVoiceBridge } = {};
let isActive = true;
const requireBridge = () => {
if (!bridgeRef.current) {
throw new Error("Realtime voice bridge is not ready");
}
return bridgeRef.current;
};
// The provider may call callbacks during createBridge(); keep the public session facade
// stable while blocking use until the bridge object has actually been returned.
const session: RealtimeVoiceBridgeSession = {
get bridge() {
return requireBridge();
},
acknowledgeMark: (markName) => requireBridge().acknowledgeMark(markName),
close: () => {
const bridge = requireBridge();
isActive = false;
bridge.close();
},
connect: () => requireBridge().connect(),
sendAudio: (audio) => requireBridge().sendAudio(audio),
sendUserMessage: (text) => requireBridge().sendUserMessage?.(text),
handleBargeIn: (options) => requireBridge().handleBargeIn?.(options),
setMediaTimestamp: (ts) => requireBridge().setMediaTimestamp(ts),
submitToolResult: (callId, result, options) => {
const bridge = requireBridge();
if (options?.suppressResponse && bridge.supportsToolResultSuppression === false) {
throw new Error("Realtime provider does not support suppressed tool results");
}
return bridge.submitToolResult(callId, result, options);
},
triggerGreeting: (instructions) => requireBridge().triggerGreeting?.(instructions),
};
const canSendAudio = () => params.audioSink.isOpen?.() ?? true;
const reportCallbackError = (error: unknown) => {
// Async tool handlers can settle after the provider closes. Once inactive, no
// callback may report stale failures into the next session lifecycle.
if (!isActive) {
return;
}
try {
params.onError?.(error instanceof Error ? error : new Error(String(error)));
} catch {
// An error callback is the terminal boundary for provider callback failures.
}
};
const bridge = params.provider.createBridge({
cfg: params.cfg,
providerConfig: params.providerConfig,
audioFormat: params.audioFormat,
instructions: params.instructions,
language: params.language,
autoRespondToAudio: params.autoRespondToAudio,
interruptResponseOnInputAudio: params.interruptResponseOnInputAudio,
tools: params.tools,
onAudio: (audio) => {
if (canSendAudio()) {
params.audioSink.sendAudio(audio);
}
},
onClearAudio: (reason) => {
if (canSendAudio()) {
params.audioSink.clearAudio?.(reason);
}
},
onMark: (markName) => {
// Some transports send mark acks, some need immediate provider acks, and some ignore
// playback marks entirely. Keep that policy centralized at the bridge boundary.
if (!canSendAudio() || params.markStrategy === "ignore") {
return;
}
if (params.markStrategy === "ack-immediately") {
bridgeRef.current?.acknowledgeMark(markName);
return;
}
if (params.markStrategy === undefined || params.markStrategy === "transport") {
params.audioSink.sendMark?.(markName);
}
},
onTranscript: params.onTranscript,
onEvent: params.onEvent,
onToolCall: (event) => {
if (!bridgeRef.current || !isActive) {
return;
}
try {
const pending = params.onToolCall?.(event, session);
if (pending) {
void pending.catch(reportCallbackError);
}
} catch (error) {
reportCallbackError(error);
}
},
onReady: () => {
if (!bridgeRef.current) {
return;
}
if (params.triggerGreetingOnReady) {
bridgeRef.current.triggerGreeting?.(params.initialGreetingInstructions);
}
params.onReady?.(session);
},
onError: params.onError,
onClose: (reason) => {
isActive = false;
params.onClose?.(reason);
},
});
bridgeRef.current = bridge;
return session;
}