@kuralle-syrinx/realtime 4.1.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/package.json +25 -5
- package/src/from-gemini-live.ts +147 -9
- package/src/from-openai-realtime.ts +30 -5
- package/src/index.ts +6 -1
- package/src/openai-compatible-realtime.ts +82 -3
- package/src/realtime-adapter.ts +28 -1
- package/src/realtime-bridge.ts +199 -10
- package/src/edge-safety.test.ts +0 -173
- package/src/from-gemini-live.test.ts +0 -241
- package/src/from-openai-realtime.test.ts +0 -675
- package/src/gemini-translate.test.ts +0 -82
- package/src/realtime-bridge.test.ts +0 -1147
- package/src/workers-seam.test.ts +0 -143
- package/tsconfig.json +0 -21
package/README.md
CHANGED
|
@@ -105,6 +105,34 @@ Cartesia, …) all dial through `createWorkersSocket` so auth headers ride on th
|
|
|
105
105
|
Regression lock: `edge-safety.test.ts` runs the adapter + bridge with `Buffer` and `process` removed from
|
|
106
106
|
`globalThis`.
|
|
107
107
|
|
|
108
|
+
### Gemini Live transcription, voice, and API version
|
|
109
|
+
|
|
110
|
+
`fromGeminiLive` keeps both transcription directions enabled by default and makes each one
|
|
111
|
+
configurable (set either to `false` to disable it):
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
const adapter = fromGeminiLive({
|
|
115
|
+
apiKey,
|
|
116
|
+
transcription: { input: true, output: true },
|
|
117
|
+
speechConfig: { voice: "Kore", languageCode: "en-US" },
|
|
118
|
+
apiVersion: "v1alpha",
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The adapter maps these options to Gemini Live's `inputAudioTranscription`,
|
|
123
|
+
`outputAudioTranscription`, and `speechConfig.voiceConfig.prebuiltVoiceConfig.voiceName` setup fields.
|
|
124
|
+
The server reports both transcripts in `serverContent.inputTranscription` and
|
|
125
|
+
`serverContent.outputTranscription`, which become `{ type: "transcript", role, text, final }` events.
|
|
126
|
+
|
|
127
|
+
Google's [Live API capabilities guide](https://ai.google.dev/gemini-api/docs/live-api/capabilities)
|
|
128
|
+
currently documents input transcription as unsupported by Gemini 3.1 Flash Live Preview. When using
|
|
129
|
+
that model, enabling `transcription.input` will not produce user transcripts; use a Live model that
|
|
130
|
+
supports input transcription or a separate STT provider when user transcripts are required. Native
|
|
131
|
+
audio models also choose their output language automatically, so `languageCode` is intended for models
|
|
132
|
+
that support explicit language selection. See Google's [API version guide](https://ai.google.dev/gemini-api/docs/api-versions)
|
|
133
|
+
for version semantics; `apiVersion` is applied at SDK client level because Live does not support
|
|
134
|
+
request-level HTTP options.
|
|
135
|
+
|
|
108
136
|
## The Responder-Thinker primitive (the delegate seam)
|
|
109
137
|
|
|
110
138
|
The bi-model shape has a name: **Responder-Thinker** — a fast realtime **responder** on the audio
|
package/package.json
CHANGED
|
@@ -1,19 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/realtime",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"private": false,
|
|
5
|
-
"
|
|
5
|
+
"description": "Realtime speech-to-speech bridge for Syrinx — OpenAI Realtime, Gemini Live, and openai-compatible adapters; the Responder-Thinker delegate seam",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"voice",
|
|
8
|
+
"voice-agent",
|
|
9
|
+
"speech",
|
|
10
|
+
"syrinx",
|
|
11
|
+
"realtime",
|
|
12
|
+
"speech-to-speech",
|
|
13
|
+
"openai-realtime",
|
|
14
|
+
"gemini-live"
|
|
15
|
+
],
|
|
6
16
|
"license": "MIT",
|
|
17
|
+
"homepage": "https://github.com/kuralle/syrinx#readme",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/kuralle/syrinx.git",
|
|
21
|
+
"directory": "packages/realtime"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/kuralle/syrinx/issues"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
7
27
|
"main": "./src/index.ts",
|
|
8
28
|
"types": "./src/index.ts",
|
|
9
29
|
"dependencies": {
|
|
10
30
|
"@google/genai": "^2.8.0",
|
|
11
|
-
"@kuralle-syrinx/
|
|
12
|
-
"@kuralle-syrinx/
|
|
31
|
+
"@kuralle-syrinx/ws": "4.3.0",
|
|
32
|
+
"@kuralle-syrinx/core": "4.3.0"
|
|
13
33
|
},
|
|
14
34
|
"devDependencies": {
|
|
15
35
|
"typescript": "^5.7.0",
|
|
16
|
-
"vitest": "^2.
|
|
36
|
+
"vitest": "^3.2.6"
|
|
17
37
|
},
|
|
18
38
|
"scripts": {
|
|
19
39
|
"typecheck": "tsc --noEmit",
|
package/src/from-gemini-live.ts
CHANGED
|
@@ -10,11 +10,35 @@ const DEFAULT_MODEL = "gemini-3.1-flash-live-preview";
|
|
|
10
10
|
const INPUT_SAMPLE_RATE_HZ = 16_000;
|
|
11
11
|
const OUTPUT_SAMPLE_RATE_HZ = 24_000;
|
|
12
12
|
|
|
13
|
+
export interface GeminiLiveTranscriptionOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Enable input transcription, or provide Gemini's AudioTranscriptionConfig. Enabled by default.
|
|
16
|
+
*
|
|
17
|
+
* Default stays ON because `RealtimeBridge` turns `role: "user"` transcripts into `stt.result`
|
|
18
|
+
* packets — defaulting this off would silently remove all user-side text on the Gemini front.
|
|
19
|
+
* Issue #32 asked for it to be *configurable*, not disabled.
|
|
20
|
+
*/
|
|
21
|
+
readonly input?: boolean | Record<string, unknown>;
|
|
22
|
+
/** Enable output transcription, or provide Gemini's AudioTranscriptionConfig. Enabled by default. */
|
|
23
|
+
readonly output?: boolean | Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface GeminiLiveSpeechConfig {
|
|
27
|
+
/** Prebuilt Gemini voice name, mapped to speechConfig.voiceConfig.prebuiltVoiceConfig.voiceName. */
|
|
28
|
+
readonly voice?: string;
|
|
29
|
+
/** ISO language code for speech synthesis. */
|
|
30
|
+
readonly languageCode?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
13
33
|
export interface GeminiLiveOptions {
|
|
14
34
|
readonly apiKey: string;
|
|
15
35
|
readonly model?: string;
|
|
16
36
|
readonly systemInstruction?: string;
|
|
17
37
|
readonly tools?: readonly RealtimeToolDef[];
|
|
38
|
+
readonly transcription?: GeminiLiveTranscriptionOptions;
|
|
39
|
+
readonly speechConfig?: GeminiLiveSpeechConfig;
|
|
40
|
+
/** Gemini Developer API version, e.g. `v1alpha` for preview-only Live features. */
|
|
41
|
+
readonly apiVersion?: string;
|
|
18
42
|
/**
|
|
19
43
|
* G4 native resume: a `sessionResumption` handle from a prior session's
|
|
20
44
|
* `resumption_handle` events. Gemini restores the conversation server-side —
|
|
@@ -23,6 +47,34 @@ export interface GeminiLiveOptions {
|
|
|
23
47
|
* prior handle back on reconnect.
|
|
24
48
|
*/
|
|
25
49
|
readonly sessionResumptionHandle?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Full `sessionResumption` LiveConnectConfig. Defaults to `{}` (handles issued)
|
|
52
|
+
* or `{ handle }` when `sessionResumptionHandle` is set. Pass `false` to omit.
|
|
53
|
+
*/
|
|
54
|
+
readonly sessionResumption?: Record<string, unknown> | false;
|
|
55
|
+
/** Response modalities. Defaults to `["AUDIO"]` (previous hard-pin). */
|
|
56
|
+
readonly responseModalities?: readonly string[];
|
|
57
|
+
/** LiveConnectConfig `generationConfig` (subset supported by Live). */
|
|
58
|
+
readonly generationConfig?: Record<string, unknown>;
|
|
59
|
+
/** LiveConnectConfig `safetySettings`. */
|
|
60
|
+
readonly safetySettings?: readonly Record<string, unknown>[];
|
|
61
|
+
readonly temperature?: number;
|
|
62
|
+
readonly topP?: number;
|
|
63
|
+
readonly topK?: number;
|
|
64
|
+
readonly maxOutputTokens?: number;
|
|
65
|
+
readonly mediaResolution?: string;
|
|
66
|
+
readonly seed?: number;
|
|
67
|
+
readonly thinkingConfig?: Record<string, unknown>;
|
|
68
|
+
readonly enableAffectiveDialog?: boolean;
|
|
69
|
+
readonly realtimeInputConfig?: Record<string, unknown>;
|
|
70
|
+
readonly contextWindowCompression?: Record<string, unknown>;
|
|
71
|
+
readonly proactivity?: Record<string, unknown>;
|
|
72
|
+
readonly explicitVadSignal?: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Merged last into `live.connect` config for any LiveConnectConfig field the
|
|
75
|
+
* adapter does not enumerate (avatarConfig, …). Overrides same-key defaults.
|
|
76
|
+
*/
|
|
77
|
+
readonly connectConfig?: Record<string, unknown>;
|
|
26
78
|
}
|
|
27
79
|
|
|
28
80
|
class GeminiLiveAdapter implements RealtimeAdapter {
|
|
@@ -61,29 +113,106 @@ class GeminiLiveAdapter implements RealtimeAdapter {
|
|
|
61
113
|
}],
|
|
62
114
|
}));
|
|
63
115
|
|
|
116
|
+
const transcription = this.opts.transcription;
|
|
64
117
|
const config: Record<string, unknown> = {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
// resumes the conversation server-side (native resume — no replay).
|
|
70
|
-
sessionResumption: this.opts.sessionResumptionHandle
|
|
71
|
-
? { handle: this.opts.sessionResumptionHandle }
|
|
72
|
-
: {},
|
|
118
|
+
// Default preserves the prior hard-pin of AUDIO-only responses.
|
|
119
|
+
responseModalities: this.opts.responseModalities
|
|
120
|
+
? [...this.opts.responseModalities]
|
|
121
|
+
: [Modality.AUDIO],
|
|
73
122
|
};
|
|
123
|
+
// G4: session resumption defaults ON so the server issues handles; a prior
|
|
124
|
+
// handle resumes server-side (native resume — no replay). Override via
|
|
125
|
+
// sessionResumption (full object) or disable with false.
|
|
126
|
+
if (!("sessionResumption" in this.opts) || this.opts.sessionResumption !== false) {
|
|
127
|
+
const base =
|
|
128
|
+
typeof this.opts.sessionResumption === "object" && this.opts.sessionResumption !== null
|
|
129
|
+
? { ...this.opts.sessionResumption }
|
|
130
|
+
: {};
|
|
131
|
+
if (this.opts.sessionResumptionHandle !== undefined) {
|
|
132
|
+
base["handle"] = this.opts.sessionResumptionHandle;
|
|
133
|
+
}
|
|
134
|
+
config["sessionResumption"] = base;
|
|
135
|
+
}
|
|
136
|
+
const inputTranscription = transcription?.input ?? true;
|
|
137
|
+
const outputTranscription = transcription?.output ?? true;
|
|
138
|
+
if (inputTranscription !== false) {
|
|
139
|
+
config["inputAudioTranscription"] = inputTranscription === true ? {} : inputTranscription;
|
|
140
|
+
}
|
|
141
|
+
if (outputTranscription !== false) {
|
|
142
|
+
config["outputAudioTranscription"] = outputTranscription === true ? {} : outputTranscription;
|
|
143
|
+
}
|
|
144
|
+
const speechConfig = this.opts.speechConfig;
|
|
145
|
+
if (speechConfig && (speechConfig.voice !== undefined || speechConfig.languageCode !== undefined)) {
|
|
146
|
+
config["speechConfig"] = {
|
|
147
|
+
...(speechConfig.voice === undefined
|
|
148
|
+
? {}
|
|
149
|
+
: { voiceConfig: { prebuiltVoiceConfig: { voiceName: speechConfig.voice } } }),
|
|
150
|
+
...(speechConfig.languageCode === undefined ? {} : { languageCode: speechConfig.languageCode }),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
74
153
|
if (this.opts.systemInstruction) {
|
|
75
154
|
config["systemInstruction"] = this.opts.systemInstruction;
|
|
76
155
|
}
|
|
77
156
|
if (tools.length > 0) {
|
|
78
157
|
config["tools"] = tools;
|
|
79
158
|
}
|
|
159
|
+
if (this.opts.generationConfig !== undefined) {
|
|
160
|
+
config["generationConfig"] = this.opts.generationConfig;
|
|
161
|
+
}
|
|
162
|
+
if (this.opts.safetySettings !== undefined) {
|
|
163
|
+
config["safetySettings"] = this.opts.safetySettings;
|
|
164
|
+
}
|
|
165
|
+
if (this.opts.temperature !== undefined) {
|
|
166
|
+
config["temperature"] = this.opts.temperature;
|
|
167
|
+
}
|
|
168
|
+
if (this.opts.topP !== undefined) {
|
|
169
|
+
config["topP"] = this.opts.topP;
|
|
170
|
+
}
|
|
171
|
+
if (this.opts.topK !== undefined) {
|
|
172
|
+
config["topK"] = this.opts.topK;
|
|
173
|
+
}
|
|
174
|
+
if (this.opts.maxOutputTokens !== undefined) {
|
|
175
|
+
config["maxOutputTokens"] = this.opts.maxOutputTokens;
|
|
176
|
+
}
|
|
177
|
+
if (this.opts.mediaResolution !== undefined) {
|
|
178
|
+
config["mediaResolution"] = this.opts.mediaResolution;
|
|
179
|
+
}
|
|
180
|
+
if (this.opts.seed !== undefined) {
|
|
181
|
+
config["seed"] = this.opts.seed;
|
|
182
|
+
}
|
|
183
|
+
if (this.opts.thinkingConfig !== undefined) {
|
|
184
|
+
config["thinkingConfig"] = this.opts.thinkingConfig;
|
|
185
|
+
}
|
|
186
|
+
if (this.opts.enableAffectiveDialog !== undefined) {
|
|
187
|
+
config["enableAffectiveDialog"] = this.opts.enableAffectiveDialog;
|
|
188
|
+
}
|
|
189
|
+
if (this.opts.realtimeInputConfig !== undefined) {
|
|
190
|
+
config["realtimeInputConfig"] = this.opts.realtimeInputConfig;
|
|
191
|
+
}
|
|
192
|
+
if (this.opts.contextWindowCompression !== undefined) {
|
|
193
|
+
config["contextWindowCompression"] = this.opts.contextWindowCompression;
|
|
194
|
+
}
|
|
195
|
+
if (this.opts.proactivity !== undefined) {
|
|
196
|
+
config["proactivity"] = this.opts.proactivity;
|
|
197
|
+
}
|
|
198
|
+
if (this.opts.explicitVadSignal !== undefined) {
|
|
199
|
+
config["explicitVadSignal"] = this.opts.explicitVadSignal;
|
|
200
|
+
}
|
|
201
|
+
if (this.opts.connectConfig) {
|
|
202
|
+
Object.assign(config, this.opts.connectConfig);
|
|
203
|
+
}
|
|
80
204
|
|
|
81
205
|
const openPromise = new Promise<void>((resolve, reject) => {
|
|
82
206
|
this.openResolver = resolve;
|
|
83
207
|
this.openRejecter = reject;
|
|
84
208
|
});
|
|
85
209
|
|
|
86
|
-
const ai = new GoogleGenAI({
|
|
210
|
+
const ai = new GoogleGenAI({
|
|
211
|
+
apiKey: this.opts.apiKey,
|
|
212
|
+
...(this.opts.apiVersion === undefined
|
|
213
|
+
? {}
|
|
214
|
+
: { httpOptions: { apiVersion: this.opts.apiVersion } }),
|
|
215
|
+
});
|
|
87
216
|
|
|
88
217
|
this.session = await ai.live.connect({
|
|
89
218
|
model,
|
|
@@ -125,6 +254,15 @@ class GeminiLiveAdapter implements RealtimeAdapter {
|
|
|
125
254
|
});
|
|
126
255
|
}
|
|
127
256
|
|
|
257
|
+
injectContext(text: string): void {
|
|
258
|
+
// Gemini Live drops system/developer roles from conversation history. A silent,
|
|
259
|
+
// incomplete user turn preserves the steering context without requesting a response.
|
|
260
|
+
this.requireSession().sendClientContent({
|
|
261
|
+
turns: [{ role: "user", parts: [{ text: `[Context-only instruction]\n${text}` }] }],
|
|
262
|
+
turnComplete: false,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
128
266
|
cancelResponse(_audioEndMs: number): void {
|
|
129
267
|
// Gemini handles interruption server-side via `interrupted`; no truncate API.
|
|
130
268
|
}
|
|
@@ -33,6 +33,19 @@ export interface OpenAIRealtimeOptions {
|
|
|
33
33
|
readonly toolChoice?: string | Record<string, unknown>;
|
|
34
34
|
readonly inputRateHz?: number;
|
|
35
35
|
readonly outputRateHz?: number;
|
|
36
|
+
/** Input audio format object. Defaults to `{ type: "audio/pcm", rate: inputRateHz }`. */
|
|
37
|
+
readonly inputAudioFormat?: Record<string, unknown>;
|
|
38
|
+
/** Output audio format object. Defaults to `{ type: "audio/pcm", rate: outputRateHz }`. */
|
|
39
|
+
readonly outputAudioFormat?: Record<string, unknown>;
|
|
40
|
+
/** OpenAI Realtime `audio.input.noise_reduction` (e.g. `{ type: "near_field" }`). */
|
|
41
|
+
readonly noiseReduction?: Record<string, unknown> | null;
|
|
42
|
+
/** OpenAI Realtime `max_output_tokens` session field. */
|
|
43
|
+
readonly maxOutputTokens?: number | "inf";
|
|
44
|
+
/**
|
|
45
|
+
* Merged last into `session.update.session` for any provider field the adapter does not enumerate
|
|
46
|
+
* (speed, tracing, prompt, include, …). Overrides same-key defaults when both set.
|
|
47
|
+
*/
|
|
48
|
+
readonly sessionConfig?: Record<string, unknown>;
|
|
36
49
|
/**
|
|
37
50
|
* G4 resume-by-replay: returns the prior conversation to replay as
|
|
38
51
|
* `conversation.item.create` items on every (re)connect — OpenAI Realtime has no
|
|
@@ -60,10 +73,11 @@ export function fromOpenAIRealtime(opts: OpenAIRealtimeOptions): RealtimeAdapter
|
|
|
60
73
|
supportsConcurrentToolAudio: true,
|
|
61
74
|
supportsTruncate: true,
|
|
62
75
|
emitsServerSpeechStarted: true,
|
|
76
|
+
supportsTextOnlyModality: opts.modalities?.length === 1 && opts.modalities[0] === "text",
|
|
63
77
|
},
|
|
64
78
|
buildSessionUpdate: () => {
|
|
65
79
|
const inputAudio: Record<string, unknown> = {
|
|
66
|
-
format: { type: "audio/pcm", rate: inputRateHz },
|
|
80
|
+
format: opts.inputAudioFormat ?? { type: "audio/pcm", rate: inputRateHz },
|
|
67
81
|
turn_detection:
|
|
68
82
|
"turnDetection" in opts ? opts.turnDetection : { type: "semantic_vad" },
|
|
69
83
|
};
|
|
@@ -71,6 +85,14 @@ export function fromOpenAIRealtime(opts: OpenAIRealtimeOptions): RealtimeAdapter
|
|
|
71
85
|
inputAudio["transcription"] =
|
|
72
86
|
opts.inputTranscription === true ? { model: "whisper-1" } : opts.inputTranscription;
|
|
73
87
|
}
|
|
88
|
+
if (opts.noiseReduction !== undefined) {
|
|
89
|
+
inputAudio["noise_reduction"] = opts.noiseReduction;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const outputAudio: Record<string, unknown> = {
|
|
93
|
+
format: opts.outputAudioFormat ?? { type: "audio/pcm", rate: outputRateHz },
|
|
94
|
+
voice,
|
|
95
|
+
};
|
|
74
96
|
|
|
75
97
|
const session: Record<string, unknown> = {
|
|
76
98
|
type: "realtime",
|
|
@@ -78,10 +100,7 @@ export function fromOpenAIRealtime(opts: OpenAIRealtimeOptions): RealtimeAdapter
|
|
|
78
100
|
output_modalities: opts.modalities ?? ["audio"],
|
|
79
101
|
audio: {
|
|
80
102
|
input: inputAudio,
|
|
81
|
-
output:
|
|
82
|
-
format: { type: "audio/pcm", rate: outputRateHz },
|
|
83
|
-
voice,
|
|
84
|
-
},
|
|
103
|
+
output: outputAudio,
|
|
85
104
|
},
|
|
86
105
|
tools: (opts.tools ?? []).map((t) => ({
|
|
87
106
|
type: "function" as const,
|
|
@@ -98,6 +117,12 @@ export function fromOpenAIRealtime(opts: OpenAIRealtimeOptions): RealtimeAdapter
|
|
|
98
117
|
if (opts.temperature !== undefined) {
|
|
99
118
|
session["temperature"] = opts.temperature;
|
|
100
119
|
}
|
|
120
|
+
if (opts.maxOutputTokens !== undefined) {
|
|
121
|
+
session["max_output_tokens"] = opts.maxOutputTokens;
|
|
122
|
+
}
|
|
123
|
+
if (opts.sessionConfig) {
|
|
124
|
+
Object.assign(session, opts.sessionConfig);
|
|
125
|
+
}
|
|
101
126
|
|
|
102
127
|
return session;
|
|
103
128
|
},
|
package/src/index.ts
CHANGED
|
@@ -12,7 +12,12 @@ export {
|
|
|
12
12
|
type OpenAiCompatibleRealtimeConfig,
|
|
13
13
|
} from "./openai-compatible-realtime.js";
|
|
14
14
|
export { fromOpenAIRealtime, type OpenAIRealtimeOptions } from "./from-openai-realtime.js";
|
|
15
|
-
export {
|
|
15
|
+
export {
|
|
16
|
+
fromGeminiLive,
|
|
17
|
+
type GeminiLiveOptions,
|
|
18
|
+
type GeminiLiveSpeechConfig,
|
|
19
|
+
type GeminiLiveTranscriptionOptions,
|
|
20
|
+
} from "./from-gemini-live.js";
|
|
16
21
|
export {
|
|
17
22
|
createGeminiTranslateSession,
|
|
18
23
|
GEMINI_TRANSLATE_MODEL,
|
|
@@ -5,7 +5,7 @@ import { RealtimeSocket } from "@kuralle-syrinx/ws/realtime";
|
|
|
5
5
|
|
|
6
6
|
import { base64ToBytes, bytesToBase64 } from "./base64.js";
|
|
7
7
|
import { RealtimeEventStream } from "./realtime-event-stream.js";
|
|
8
|
-
import type { RealtimeAdapter, RealtimeEvent, RealtimeResumeMessage } from "./realtime-adapter.js";
|
|
8
|
+
import type { RealtimeAdapter, RealtimeEvent, RealtimeResumeMessage, RealtimeUsage } from "./realtime-adapter.js";
|
|
9
9
|
|
|
10
10
|
export interface OpenAiCompatibleRealtimeConfig {
|
|
11
11
|
readonly apiKey: string;
|
|
@@ -18,6 +18,11 @@ export interface OpenAiCompatibleRealtimeConfig {
|
|
|
18
18
|
readonly buildUrl?: (model: string) => string;
|
|
19
19
|
readonly caps: RealtimeAdapter["caps"];
|
|
20
20
|
readonly buildSessionUpdate: () => Record<string, unknown>;
|
|
21
|
+
/**
|
|
22
|
+
* Optional fields merged into `session.update.session` after `buildSessionUpdate()`.
|
|
23
|
+
* Use for provider-specific knobs the adapter does not enumerate (mirrors TTS `extra_body`).
|
|
24
|
+
*/
|
|
25
|
+
readonly sessionExtra?: Record<string, unknown> | (() => Record<string, unknown>);
|
|
21
26
|
/**
|
|
22
27
|
* G4 resume-by-replay: returns the prior conversation to re-create as
|
|
23
28
|
* `conversation.item.create` items after each (re)connect's `session.update` —
|
|
@@ -117,9 +122,14 @@ class OpenAiCompatibleRealtimeAdapter implements RealtimeAdapter {
|
|
|
117
122
|
private applySessionConfig(): void {
|
|
118
123
|
const reconnected = this.opened;
|
|
119
124
|
this.opened = true;
|
|
125
|
+
const session = this.config.buildSessionUpdate();
|
|
126
|
+
const extra =
|
|
127
|
+
typeof this.config.sessionExtra === "function"
|
|
128
|
+
? this.config.sessionExtra()
|
|
129
|
+
: this.config.sessionExtra;
|
|
120
130
|
this.socket?.send({
|
|
121
131
|
type: "session.update",
|
|
122
|
-
session:
|
|
132
|
+
session: extra ? { ...session, ...extra } : session,
|
|
123
133
|
});
|
|
124
134
|
// G4 resume-by-replay: re-create the prior conversation as items. Providers
|
|
125
135
|
// without native resume start every (re)connection amnesiac; replaying the
|
|
@@ -171,6 +181,22 @@ class OpenAiCompatibleRealtimeAdapter implements RealtimeAdapter {
|
|
|
171
181
|
this.requestResponseCreate();
|
|
172
182
|
}
|
|
173
183
|
|
|
184
|
+
injectContext(text: string): void {
|
|
185
|
+
this.requireSocket().send({
|
|
186
|
+
type: "conversation.item.create",
|
|
187
|
+
item: {
|
|
188
|
+
type: "message",
|
|
189
|
+
role: "system",
|
|
190
|
+
content: [{ type: "input_text", text }],
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
requestResponse(): void {
|
|
196
|
+
this.requireSocket().send({ type: "input_audio_buffer.commit" });
|
|
197
|
+
this.requestResponseCreate();
|
|
198
|
+
}
|
|
199
|
+
|
|
174
200
|
cancelResponse(audioEndMs: number): void {
|
|
175
201
|
if (!this.activeResponse) return;
|
|
176
202
|
const socket = this.requireSocket();
|
|
@@ -306,6 +332,9 @@ class OpenAiCompatibleRealtimeAdapter implements RealtimeAdapter {
|
|
|
306
332
|
case "input_audio_buffer.speech_started":
|
|
307
333
|
this.stream.push({ type: "speech_started" });
|
|
308
334
|
break;
|
|
335
|
+
case "input_audio_buffer.speech_stopped":
|
|
336
|
+
this.stream.push({ type: "speech_stopped" });
|
|
337
|
+
break;
|
|
309
338
|
case "response.output_audio_transcript.delta": {
|
|
310
339
|
const delta = msg["delta"];
|
|
311
340
|
if (typeof delta === "string" && delta.length > 0) {
|
|
@@ -331,6 +360,30 @@ class OpenAiCompatibleRealtimeAdapter implements RealtimeAdapter {
|
|
|
331
360
|
this.assistantTranscript = "";
|
|
332
361
|
break;
|
|
333
362
|
}
|
|
363
|
+
case "response.output_text.delta": {
|
|
364
|
+
const delta = msg["delta"];
|
|
365
|
+
if (typeof delta === "string" && delta.length > 0) {
|
|
366
|
+
this.assistantTranscript += delta;
|
|
367
|
+
this.stream.push({
|
|
368
|
+
type: "transcript",
|
|
369
|
+
role: "assistant",
|
|
370
|
+
text: delta,
|
|
371
|
+
final: false,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
case "response.output_text.done": {
|
|
377
|
+
const t = typeof msg["text"] === "string" ? msg["text"] : this.assistantTranscript;
|
|
378
|
+
this.stream.push({
|
|
379
|
+
type: "transcript",
|
|
380
|
+
role: "assistant",
|
|
381
|
+
text: t,
|
|
382
|
+
final: true,
|
|
383
|
+
});
|
|
384
|
+
this.assistantTranscript = "";
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
334
387
|
case "conversation.item.input_audio_transcription.completed": {
|
|
335
388
|
// User-side transcript (requires input transcription enabled in session config).
|
|
336
389
|
const transcript = typeof msg["transcript"] === "string" ? msg["transcript"] : "";
|
|
@@ -345,7 +398,10 @@ class OpenAiCompatibleRealtimeAdapter implements RealtimeAdapter {
|
|
|
345
398
|
if (toolCall) {
|
|
346
399
|
this.stream.push(toolCall);
|
|
347
400
|
}
|
|
348
|
-
|
|
401
|
+
// response.usage is snake_case token counts; forward so the bridge can meter
|
|
402
|
+
// the native front (previously native turns produced NO usage at all).
|
|
403
|
+
const usage = extractRealtimeUsage(msg["response"]);
|
|
404
|
+
this.stream.push(usage ? { type: "response_done", usage } : { type: "response_done" });
|
|
349
405
|
break;
|
|
350
406
|
}
|
|
351
407
|
case "error": {
|
|
@@ -356,6 +412,14 @@ class OpenAiCompatibleRealtimeAdapter implements RealtimeAdapter {
|
|
|
356
412
|
: this.config.defaultErrorMessage;
|
|
357
413
|
const code =
|
|
358
414
|
errObj && typeof errObj === "object" ? (errObj as Record<string, unknown>)["code"] : undefined;
|
|
415
|
+
// A `response.cancel` that raced a just-completed response ("no active response found")
|
|
416
|
+
// is benign — the cancel is a server-side no-op. This is the multi-turn barge-in cancel
|
|
417
|
+
// race (a new turn's speech_started fires a cancel as the prior response completes). Do
|
|
418
|
+
// NOT surface it: it would trip strict consumers and pollute error-rate monitoring.
|
|
419
|
+
const codeStr = typeof code === "string" ? code : "";
|
|
420
|
+
if (codeStr === "response_cancel_not_active" || /no active response|cancellation failed/i.test(message)) {
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
359
423
|
const recoverable = code !== "invalid_api_key" && code !== "authentication_failed";
|
|
360
424
|
this.stream.push({
|
|
361
425
|
type: "error",
|
|
@@ -370,6 +434,21 @@ class OpenAiCompatibleRealtimeAdapter implements RealtimeAdapter {
|
|
|
370
434
|
}
|
|
371
435
|
}
|
|
372
436
|
|
|
437
|
+
/** Pull snake_case token counts off a realtime response.usage object, when present. */
|
|
438
|
+
function extractRealtimeUsage(response: unknown): RealtimeUsage | undefined {
|
|
439
|
+
if (!response || typeof response !== "object") return undefined;
|
|
440
|
+
const usage = (response as Record<string, unknown>)["usage"];
|
|
441
|
+
if (!usage || typeof usage !== "object") return undefined;
|
|
442
|
+
const u = usage as Record<string, unknown>;
|
|
443
|
+
const num = (v: unknown): number | undefined => (typeof v === "number" ? v : undefined);
|
|
444
|
+
const out: RealtimeUsage = {
|
|
445
|
+
...(num(u["input_tokens"]) !== undefined ? { inputTokens: num(u["input_tokens"]) } : {}),
|
|
446
|
+
...(num(u["output_tokens"]) !== undefined ? { outputTokens: num(u["output_tokens"]) } : {}),
|
|
447
|
+
...(num(u["total_tokens"]) !== undefined ? { totalTokens: num(u["total_tokens"]) } : {}),
|
|
448
|
+
};
|
|
449
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
450
|
+
}
|
|
451
|
+
|
|
373
452
|
function extractFunctionCall(response: unknown): RealtimeEvent | null {
|
|
374
453
|
if (!response || typeof response !== "object") return null;
|
|
375
454
|
const output = (response as Record<string, unknown>)["output"];
|
package/src/realtime-adapter.ts
CHANGED
|
@@ -14,6 +14,16 @@ export interface RealtimeAdapter {
|
|
|
14
14
|
* be replayed — that would double-apply history (RFC bimodel-delegate-seam R6).
|
|
15
15
|
*/
|
|
16
16
|
readonly supportsNativeResume?: boolean;
|
|
17
|
+
/** The front model owns full-duplex interaction decisions (turn-taking, barge-in). When true,
|
|
18
|
+
* Syrinx's InteractionPolicy runs observe-only and does not drive its own turn/interrupt decisions
|
|
19
|
+
* (RFC InteractionPolicy REQ-4). Absent/false → Syrinx drives. No current adapter sets this. */
|
|
20
|
+
readonly supportsFullDuplex?: boolean;
|
|
21
|
+
/** The front emits its own backchannels ("mhmm"). When true, Syrinx suppresses its own backchannel
|
|
22
|
+
* cues (RFC InteractionPolicy REQ-4). Absent/false → Syrinx may emit. */
|
|
23
|
+
readonly emitsBackchannel?: boolean;
|
|
24
|
+
/** Half-cascade: provider can run text-only modality (`modalities:["text"]`) so Syrinx TTS
|
|
25
|
+
* drives speech from assistant transcript events. Absent/false → no text-only half-cascade. */
|
|
26
|
+
readonly supportsTextOnlyModality?: boolean;
|
|
17
27
|
};
|
|
18
28
|
|
|
19
29
|
open(signal: AbortSignal): Promise<void>;
|
|
@@ -23,6 +33,15 @@ export interface RealtimeAdapter {
|
|
|
23
33
|
* provider cannot accept text input omit it, and the bridge silently ignores typed turns for them.
|
|
24
34
|
*/
|
|
25
35
|
sendText?(text: string): void;
|
|
36
|
+
/** Inject transient context without requesting a provider response. */
|
|
37
|
+
injectContext?(text: string): void;
|
|
38
|
+
/**
|
|
39
|
+
* Commit any buffered user input and request a response. For Syrinx-OWNED turn detection
|
|
40
|
+
* (provider server VAD disabled via turnDetection:null): the host calls this when its own
|
|
41
|
+
* endpointing (InteractionPolicy / VAD) signals end-of-turn. Optional — adapters without
|
|
42
|
+
* manual turn control omit it.
|
|
43
|
+
*/
|
|
44
|
+
requestResponse?(): void;
|
|
26
45
|
cancelResponse(audioEndMs: number): void;
|
|
27
46
|
injectToolResult(toolId: string, text: string): void;
|
|
28
47
|
/** Close the provider socket and end the event stream. */
|
|
@@ -47,13 +66,21 @@ export interface RealtimeResumeMessage {
|
|
|
47
66
|
readonly content: string;
|
|
48
67
|
}
|
|
49
68
|
|
|
69
|
+
/** Token usage a realtime provider reports on response completion (e.g. OpenAI response.done). */
|
|
70
|
+
export interface RealtimeUsage {
|
|
71
|
+
readonly inputTokens?: number;
|
|
72
|
+
readonly outputTokens?: number;
|
|
73
|
+
readonly totalTokens?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
50
76
|
export type RealtimeEvent =
|
|
51
77
|
| { type: "audio"; pcm16: Uint8Array; sampleRateHz: number }
|
|
52
78
|
| { type: "speech_started" }
|
|
79
|
+
| { type: "speech_stopped" }
|
|
53
80
|
| { type: "transcript"; role: "user" | "assistant"; text: string; final: boolean }
|
|
54
81
|
| { type: "tool_call"; toolId: string; toolName: string; args: Record<string, unknown> }
|
|
55
82
|
| { type: "response_started" }
|
|
56
|
-
| { type: "response_done" }
|
|
83
|
+
| { type: "response_done"; usage?: RealtimeUsage }
|
|
57
84
|
// G4: a native-resume provider issued a fresh resumption handle — persist the
|
|
58
85
|
// latest one and pass it back on reconnect (Gemini `sessionResumption`).
|
|
59
86
|
| { type: "resumption_handle"; handle: string }
|