ai 7.0.101 → 7.0.102

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.
Files changed (31) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/index.d.ts +82 -20
  3. package/dist/index.js +2010 -330
  4. package/dist/index.js.map +1 -1
  5. package/dist/internal/index.js +1 -1
  6. package/docs/03-agents/07-workflow-agent.mdx +1 -1
  7. package/docs/03-ai-sdk-core/36-realtime.mdx +41 -18
  8. package/docs/04-ai-sdk-ui/21-transport.mdx +1 -1
  9. package/docs/07-reference/02-ai-sdk-ui/05-use-realtime.mdx +364 -48
  10. package/docs/07-reference/04-ai-sdk-workflow/02-workflow-chat-transport.mdx +4 -4
  11. package/docs/07-reference/05-ai-sdk-errors/index.mdx +131 -36
  12. package/package.json +12 -12
  13. package/src/generate-text/execute-tools-from-stream.ts +7 -0
  14. package/src/generate-text/stream-text.ts +3 -1
  15. package/src/realtime/__fixtures__/fake-live-websocket.ts +71 -0
  16. package/src/realtime/__fixtures__/fake-realtime.ts +36 -0
  17. package/src/realtime/__fixtures__/fake-webrtc.ts +133 -0
  18. package/src/realtime/browser-realtime-audio.ts +107 -10
  19. package/src/realtime/browser-realtime-live-websocket.ts +247 -0
  20. package/src/realtime/browser-realtime-transport.ts +235 -69
  21. package/src/realtime/browser-realtime-webrtc.ts +582 -0
  22. package/src/realtime/encode-realtime-frame.ts +33 -0
  23. package/src/realtime/index.ts +1 -0
  24. package/src/realtime/realtime-attempt.ts +45 -0
  25. package/src/realtime/realtime-command-tracker.ts +81 -0
  26. package/src/realtime/realtime-event-channel.ts +170 -0
  27. package/src/realtime/realtime-event-reducer.ts +3 -0
  28. package/src/realtime/realtime-session-state.ts +65 -0
  29. package/src/realtime/realtime-session.ts +768 -218
  30. package/src/realtime/realtime-types.ts +1 -1
  31. package/src/realtime/validate-realtime-setup.ts +37 -0
@@ -10,6 +10,8 @@ export type BrowserRealtimeAudioOptions = {
10
10
  onAudio: (base64Audio: string) => void;
11
11
  onPlayingChange: (isPlaying: boolean) => void;
12
12
  onCapturingChange: (isCapturing: boolean) => void;
13
+ maxPlaybackSeconds?: number;
14
+ onError?: (error: Error) => void;
13
15
  };
14
16
 
15
17
  export class BrowserRealtimeAudio {
@@ -23,6 +25,9 @@ export class BrowserRealtimeAudio {
23
25
  private captureProcessor: ScriptProcessorNode | null = null;
24
26
  private captureSource: MediaStreamAudioSourceNode | null = null;
25
27
  private captureStream: MediaStream | null = null;
28
+ private ownsCaptureStream = true;
29
+ private captureCleanup: Array<() => void> = [];
30
+ private captureGeneration = 0;
26
31
 
27
32
  private playbackContext: AudioContext | null = null;
28
33
  private playbackQueue: Float32Array[] = [];
@@ -30,12 +35,17 @@ export class BrowserRealtimeAudio {
30
35
  private playbackStartTime = 0;
31
36
  private activeSources = new Set<AudioBufferSourceNode>();
32
37
  private isPlaying = false;
38
+ private playbackPaused = false;
39
+ private readonly maxPlaybackSeconds: number;
40
+ private readonly onError: ((error: Error) => void) | undefined;
33
41
 
34
42
  constructor(options: BrowserRealtimeAudioOptions) {
35
43
  this.captureSampleRate = options.captureSampleRate;
36
44
  this.playbackSampleRate = options.playbackSampleRate;
37
45
  this.onAudio = options.onAudio;
38
46
  this.onPlayingChange = options.onPlayingChange;
47
+ this.maxPlaybackSeconds = options.maxPlaybackSeconds ?? Infinity;
48
+ this.onError = options.onError;
39
49
  this.onCapturingChange = options.onCapturingChange;
40
50
  }
41
51
 
@@ -44,40 +54,101 @@ export class BrowserRealtimeAudio {
44
54
  this.playbackContext = new AudioContext({
45
55
  sampleRate: this.playbackSampleRate,
46
56
  });
57
+ const context = this.playbackContext;
58
+ context.onstatechange = () => {
59
+ if (this.playbackContext === context)
60
+ this.setPlaying(
61
+ context.state === 'running' && this.activeSources.size > 0,
62
+ );
63
+ };
47
64
  }
48
65
  }
49
66
 
50
- startCapture(stream: MediaStream): void {
67
+ async resumePlayback(): Promise<void> {
68
+ this.ensurePlaybackContext();
69
+ const context = this.playbackContext;
70
+ if (this.playbackPaused) this.stopPlayback();
71
+ if (this.playbackContext !== context) return;
72
+ await context?.resume();
73
+ if (this.playbackContext !== context) return;
74
+ this.playbackPaused = false;
75
+ this.setPlaying(
76
+ this.playbackContext?.state === 'running' && this.activeSources.size > 0,
77
+ );
78
+ }
79
+
80
+ startCapture(stream: MediaStream, options?: { ownsStream?: boolean }): void {
81
+ const generation = this.captureGeneration + 1;
82
+ this.stopCapture();
83
+ if (generation !== this.captureGeneration) {
84
+ if (options?.ownsStream ?? true)
85
+ stream.getTracks().forEach(track => track.stop());
86
+ return;
87
+ }
88
+ this.captureStream = stream;
89
+ this.ownsCaptureStream = options?.ownsStream ?? true;
51
90
  const ctx = new AudioContext({ sampleRate: this.captureSampleRate });
52
91
  this.captureContext = ctx;
53
- this.captureStream = stream;
92
+ void ctx.resume().catch(error => {
93
+ if (this.captureContext === ctx) this.onError?.(error);
94
+ });
54
95
 
55
96
  const source = ctx.createMediaStreamSource(stream);
56
97
  this.captureSource = source;
57
98
 
58
- const processor = ctx.createScriptProcessor(4096, 1, 1);
99
+ const processor = ctx.createScriptProcessor(1024, 1, 1);
59
100
  this.captureProcessor = processor;
60
101
 
61
102
  processor.onaudioprocess = event => {
103
+ if (this.captureContext !== ctx) return;
62
104
  const inputData = event.inputBuffer.getChannelData(0);
63
105
  const samples = resampleAudio(
64
106
  new Float32Array(inputData),
65
107
  ctx.sampleRate,
66
108
  this.captureSampleRate,
67
109
  );
68
- this.onAudio(encodeRealtimeAudio(samples));
110
+ try {
111
+ this.onAudio(encodeRealtimeAudio(samples));
112
+ } catch (error) {
113
+ this.onError?.(
114
+ error instanceof Error ? error : new Error(String(error)),
115
+ );
116
+ }
69
117
  };
70
118
 
71
119
  source.connect(processor);
72
120
  processor.connect(ctx.destination);
73
- this.onCapturingChange(true);
121
+ const updateCapturing = () =>
122
+ this.onCapturingChange(
123
+ stream
124
+ .getAudioTracks()
125
+ .some(
126
+ track =>
127
+ track.enabled && !track.muted && track.readyState === 'live',
128
+ ),
129
+ );
130
+ for (const track of stream.getAudioTracks()) {
131
+ for (const event of ['ended', 'mute', 'unmute']) {
132
+ track.addEventListener(event, updateCapturing);
133
+ this.captureCleanup.push(() =>
134
+ track.removeEventListener(event, updateCapturing),
135
+ );
136
+ }
137
+ }
138
+ updateCapturing();
74
139
  }
75
140
 
76
141
  stopCapture(): void {
142
+ this.captureGeneration++;
143
+ if (this.captureProcessor != null)
144
+ this.captureProcessor.onaudioprocess = null;
145
+ for (const cleanup of this.captureCleanup) cleanup();
146
+ this.captureCleanup = [];
77
147
  this.captureProcessor?.disconnect();
78
148
  this.captureSource?.disconnect();
79
- void this.captureContext?.close();
80
- this.captureStream?.getTracks().forEach(track => track.stop());
149
+ void this.captureContext?.close().catch(() => {});
150
+ if (this.ownsCaptureStream)
151
+ this.captureStream?.getTracks().forEach(track => track.stop());
81
152
 
82
153
  this.captureProcessor = null;
83
154
  this.captureSource = null;
@@ -87,8 +158,27 @@ export class BrowserRealtimeAudio {
87
158
  }
88
159
 
89
160
  playAudio(base64Audio: string): void {
161
+ if (this.playbackPaused) return;
90
162
  this.ensurePlaybackContext();
91
- this.playbackQueue.push(decodeRealtimeAudio(base64Audio));
163
+ const samples = decodeRealtimeAudio(base64Audio);
164
+ if (
165
+ Math.max(
166
+ 0,
167
+ this.playbackTime - (this.playbackContext?.currentTime ?? 0),
168
+ ) +
169
+ samples.length / this.playbackSampleRate >
170
+ this.maxPlaybackSeconds
171
+ ) {
172
+ this.playbackPaused = true;
173
+ this.stopPlayback();
174
+ this.onError?.(
175
+ new Error(
176
+ 'Realtime audio playback buffer is full; playback paused, call resumePlayback() to resume at the live edge',
177
+ ),
178
+ );
179
+ return;
180
+ }
181
+ this.playbackQueue.push(samples);
92
182
  this.schedulePlayback();
93
183
  }
94
184
 
@@ -97,7 +187,9 @@ export class BrowserRealtimeAudio {
97
187
 
98
188
  for (const source of this.activeSources) {
99
189
  try {
190
+ source.onended = null;
100
191
  source.stop();
192
+ source.disconnect();
101
193
  } catch {
102
194
  // Source may already be stopped by the browser.
103
195
  }
@@ -119,8 +211,11 @@ export class BrowserRealtimeAudio {
119
211
  dispose(): void {
120
212
  this.stopCapture();
121
213
  this.stopPlayback();
122
- void this.playbackContext?.close();
214
+ if (this.playbackContext != null) this.playbackContext.onstatechange = null;
215
+ void this.playbackContext?.close().catch(() => {});
123
216
  this.playbackContext = null;
217
+ this.playbackTime = 0;
218
+ this.playbackPaused = false;
124
219
  }
125
220
 
126
221
  private setPlaying(isPlaying: boolean): void {
@@ -155,9 +250,11 @@ export class BrowserRealtimeAudio {
155
250
  this.playbackTime = startTime + buffer.duration;
156
251
 
157
252
  this.activeSources.add(source);
158
- this.setPlaying(true);
253
+ this.setPlaying(ctx.state === 'running');
254
+ if (this.playbackContext !== ctx) return;
159
255
 
160
256
  source.onended = () => {
257
+ source.disconnect();
161
258
  this.activeSources.delete(source);
162
259
  if (this.playbackQueue.length === 0 && this.activeSources.size === 0) {
163
260
  this.setPlaying(false);
@@ -0,0 +1,247 @@
1
+ import { UnsupportedFunctionalityError } from '@ai-sdk/provider';
2
+ import type {
3
+ RealtimeClientEvent,
4
+ RealtimeModel,
5
+ RealtimeServerEvent,
6
+ RealtimeSessionConfig,
7
+ } from '../types/realtime-model';
8
+ import { BrowserRealtimeAudio } from './browser-realtime-audio';
9
+ import { BrowserRealtimeTransport } from './browser-realtime-transport';
10
+
11
+ /** Continuous PCM16 audio-chunk media for an application-owned WebSocket relay. */
12
+ export class BrowserRealtimeLiveWebSocket {
13
+ private readonly transport: BrowserRealtimeTransport;
14
+ private readonly audio: BrowserRealtimeAudio;
15
+ private readonly config: RealtimeSessionConfig;
16
+ private generation = 0;
17
+ private ready = false;
18
+ private capturingStarted = false;
19
+ private stream?: MediaStream;
20
+ private pendingAudio = 0;
21
+ private captureGeneration = 0;
22
+ private captureEnabled = true;
23
+
24
+ constructor(
25
+ private readonly options: {
26
+ model: RealtimeModel;
27
+ sessionConfig?: Partial<RealtimeSessionConfig>;
28
+ sampleRate?: number;
29
+ maxPlaybackBufferSeconds?: number;
30
+ onEvent: (event: RealtimeServerEvent) => Promise<void>;
31
+ onError: (error: Error) => void;
32
+ onFatalError: (error: Error, drain?: Promise<void>) => void;
33
+ onClosing?: () => void;
34
+ onClose: (error?: Error) => void;
35
+ onCapturing: (value: boolean) => void;
36
+ onPlaying: (value: boolean) => void;
37
+ },
38
+ ) {
39
+ const input = options.sessionConfig?.inputAudioFormat;
40
+ const output = options.sessionConfig?.outputAudioFormat;
41
+ const sampleRate =
42
+ input?.rate ?? output?.rate ?? options.sampleRate ?? 24000;
43
+ const format = {
44
+ type: input?.type ?? output?.type ?? 'audio/pcm',
45
+ rate: sampleRate,
46
+ };
47
+ this.config = {
48
+ ...options.sessionConfig,
49
+ inputAudioFormat: {
50
+ ...format,
51
+ ...input,
52
+ },
53
+ outputAudioFormat: {
54
+ ...format,
55
+ ...output,
56
+ },
57
+ };
58
+ this.transport = new BrowserRealtimeTransport({
59
+ model: options.model,
60
+ onServerEvent: options.onEvent,
61
+ onError: options.onError,
62
+ onFatalError: options.onFatalError,
63
+ onClosing: () => {
64
+ const generation = this.generation;
65
+ options.onClosing?.();
66
+ if (generation === this.generation) this.stopCapture();
67
+ },
68
+ onClose: options.onClose,
69
+ });
70
+ this.audio = new BrowserRealtimeAudio({
71
+ captureSampleRate: this.config.inputAudioFormat?.rate ?? sampleRate,
72
+ playbackSampleRate: this.config.outputAudioFormat?.rate ?? sampleRate,
73
+ maxPlaybackSeconds: options.maxPlaybackBufferSeconds ?? 2,
74
+ onAudio: audio => this.sendAudio(audio),
75
+ onError: options.onError,
76
+ onCapturingChange: options.onCapturing,
77
+ onPlayingChange: options.onPlaying,
78
+ });
79
+ }
80
+
81
+ connect(options: {
82
+ url: string;
83
+ protocols?: string[];
84
+ stream?: MediaStream;
85
+ capture?: boolean;
86
+ }): void {
87
+ const generation = this.generation + 1;
88
+ this.dispose();
89
+ if (generation !== this.generation) return;
90
+ for (const format of [
91
+ this.config.inputAudioFormat,
92
+ this.config.outputAudioFormat,
93
+ ]) {
94
+ if (format?.type !== 'audio/pcm')
95
+ throw new UnsupportedFunctionalityError({
96
+ functionality:
97
+ 'Continuous browser WebSocket audio supports PCM16 only',
98
+ });
99
+ if (
100
+ format.rate == null ||
101
+ !Number.isFinite(format.rate) ||
102
+ format.rate < 8000 ||
103
+ format.rate > 96000
104
+ )
105
+ throw new Error('Invalid realtime PCM sample rate');
106
+ }
107
+ this.stream = options.stream;
108
+ this.captureEnabled = options.capture !== false;
109
+ // Create/resume playback while connect is still in the caller's user gesture.
110
+ this.audio.ensurePlaybackContext();
111
+ void this.audio.resumePlayback().catch(error => {
112
+ if (generation === this.generation) this.options.onError(error);
113
+ });
114
+ this.transport.connect({
115
+ mode: 'relay',
116
+ url: options.url,
117
+ protocols: options.protocols,
118
+ onOpen: () => {
119
+ return this.transport.sendEvent({
120
+ type: this.options.model.capabilities?.startup ?? 'session-update',
121
+ config: this.config,
122
+ });
123
+ },
124
+ });
125
+ }
126
+
127
+ startCapture(): void {
128
+ this.ready = true;
129
+ if (!this.captureEnabled || this.capturingStarted) return;
130
+ void this.resumeCapture().catch(error => this.options.onError(error));
131
+ }
132
+
133
+ async resumeCapture(suppliedStream?: MediaStream): Promise<void> {
134
+ const generation = this.captureGeneration + 1;
135
+ this.stopCapture();
136
+ if (generation !== this.captureGeneration) return;
137
+ this.ready = true;
138
+ this.capturingStarted = true;
139
+ this.captureEnabled = true;
140
+ if (suppliedStream != null) this.stream = suppliedStream;
141
+ const supplied = this.stream;
142
+ const stream =
143
+ supplied ?? (await navigator.mediaDevices.getUserMedia({ audio: true }));
144
+ if (generation !== this.captureGeneration || !this.ready) {
145
+ if (supplied == null) stream.getTracks().forEach(track => track.stop());
146
+ return;
147
+ }
148
+ if (!stream.getAudioTracks().some(track => track.readyState === 'live')) {
149
+ if (supplied == null) stream.getTracks().forEach(track => track.stop());
150
+ throw new Error('Realtime requires a live audio track');
151
+ }
152
+ this.audio.startCapture(stream, { ownsStream: supplied == null });
153
+ }
154
+
155
+ private sendAudio(audio: string): void {
156
+ if (!this.ready || !this.transport.isOpen) return;
157
+ if (this.pendingAudio >= 8) {
158
+ this.transport.fail(new Error('Realtime audio send queue is full'));
159
+ return;
160
+ }
161
+ const generation = this.generation;
162
+ let sent: Promise<void>;
163
+ try {
164
+ sent = this.transport.sendEvent(
165
+ { type: 'input-audio-append', audio },
166
+ undefined,
167
+ true,
168
+ );
169
+ } catch (error) {
170
+ if (!this.ready || !this.transport.isOpen) return;
171
+ throw error;
172
+ }
173
+ this.pendingAudio++;
174
+ void sent.then(
175
+ () => {
176
+ if (generation === this.generation) this.pendingAudio--;
177
+ },
178
+ error => {
179
+ if (
180
+ generation === this.generation &&
181
+ this.ready &&
182
+ this.transport.isOpen
183
+ )
184
+ this.options.onFatalError(error);
185
+ },
186
+ );
187
+ }
188
+
189
+ sendEvent(
190
+ event: RealtimeClientEvent,
191
+ shouldSend?: () => boolean,
192
+ ): Promise<void> {
193
+ if (event.type === 'session-update') {
194
+ for (const key of ['inputAudioFormat', 'outputAudioFormat'] as const) {
195
+ const update = event.config[key];
196
+ if (
197
+ update != null &&
198
+ (update.type !== this.config[key]?.type ||
199
+ (update.rate != null && update.rate !== this.config[key]?.rate))
200
+ ) {
201
+ throw new Error(
202
+ 'Changing Live WebSocket audio format requires reconnecting',
203
+ );
204
+ }
205
+ }
206
+ }
207
+ return this.transport.sendEvent(event, shouldSend);
208
+ }
209
+ playAudio(audio: string): void {
210
+ try {
211
+ this.audio.playAudio(audio);
212
+ } catch (error) {
213
+ this.options.onError(
214
+ error instanceof Error ? error : new Error(String(error)),
215
+ );
216
+ }
217
+ }
218
+ stopPlayback(): void {
219
+ this.audio.stopPlayback();
220
+ }
221
+ resumePlayback(): Promise<void> {
222
+ return this.audio.resumePlayback();
223
+ }
224
+
225
+ stopCapture(): void {
226
+ this.captureGeneration++;
227
+ this.ready = false;
228
+ this.capturingStarted = false;
229
+ this.captureEnabled = false;
230
+ this.audio.stopCapture();
231
+ }
232
+
233
+ finish(): Promise<void> {
234
+ return this.transport.finish();
235
+ }
236
+
237
+ dispose(): void {
238
+ this.generation++;
239
+ this.stopCapture();
240
+ this.ready = false;
241
+ this.capturingStarted = false;
242
+ this.pendingAudio = 0;
243
+ this.stream = undefined;
244
+ this.transport.disconnect();
245
+ this.audio.dispose();
246
+ }
247
+ }