@mastra/voice-aws-nova-sonic 0.2.0 → 0.2.1

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/dist/index.cjs CHANGED
@@ -1,1877 +1,1696 @@
1
- 'use strict';
2
-
3
- var crypto = require('crypto');
4
- var stream = require('stream');
5
- var clientBedrockRuntime = require('@aws-sdk/client-bedrock-runtime');
6
- var nodeHttpHandler = require('@smithy/node-http-handler');
7
- var credentialProviderNode = require('@aws-sdk/credential-provider-node');
8
-
9
- // src/index.ts
10
-
11
- // ../../packages/_internal-core/dist/chunk-3M4SEWMI.js
12
- var RegisteredLogger = {
13
- LLM: "LLM"};
14
- var LogLevel = {
15
- DEBUG: "debug",
16
- INFO: "info",
17
- WARN: "warn",
18
- ERROR: "error"};
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let crypto = require("crypto");
3
+ let stream = require("stream");
4
+ let _aws_sdk_client_bedrock_runtime = require("@aws-sdk/client-bedrock-runtime");
5
+ let _smithy_node_http_handler = require("@smithy/node-http-handler");
6
+ let _aws_sdk_credential_provider_node = require("@aws-sdk/credential-provider-node");
7
+ //#region ../../packages/_internal-core/dist/logger/index.js
8
+ const RegisteredLogger = {
9
+ AGENT: "AGENT",
10
+ OBSERVABILITY: "OBSERVABILITY",
11
+ AUTH: "AUTH",
12
+ BROWSER: "BROWSER",
13
+ NETWORK: "NETWORK",
14
+ WORKFLOW: "WORKFLOW",
15
+ LLM: "LLM",
16
+ TTS: "TTS",
17
+ VOICE: "VOICE",
18
+ VECTOR: "VECTOR",
19
+ BUNDLER: "BUNDLER",
20
+ DEPLOYER: "DEPLOYER",
21
+ MEMORY: "MEMORY",
22
+ STORAGE: "STORAGE",
23
+ EMBEDDINGS: "EMBEDDINGS",
24
+ MCP_SERVER: "MCP_SERVER",
25
+ SERVER_CACHE: "SERVER_CACHE",
26
+ SERVER: "SERVER",
27
+ WORKSPACE: "WORKSPACE",
28
+ CHANNEL: "CHANNEL"
29
+ };
30
+ const LogLevel = {
31
+ DEBUG: "debug",
32
+ INFO: "info",
33
+ WARN: "warn",
34
+ ERROR: "error",
35
+ NONE: "silent"
36
+ };
19
37
  var MastraLogger = class {
20
- name;
21
- level;
22
- transports;
23
- constructor(options = {}) {
24
- this.name = options.name || "Mastra";
25
- this.level = options.level || LogLevel.ERROR;
26
- this.transports = new Map(Object.entries(options.transports || {}));
27
- }
28
- getTransports() {
29
- return this.transports;
30
- }
31
- trackException(_error, _metadata) {
32
- }
33
- async listLogs(transportId, params) {
34
- if (!transportId || !this.transports.has(transportId)) {
35
- return { logs: [], total: 0, page: params?.page ?? 1, perPage: params?.perPage ?? 100, hasMore: false };
36
- }
37
- return this.transports.get(transportId).listLogs?.(params) ?? {
38
- logs: [],
39
- total: 0,
40
- page: params?.page ?? 1,
41
- perPage: params?.perPage ?? 100,
42
- hasMore: false
43
- };
44
- }
45
- async listLogsByRunId({
46
- transportId,
47
- runId,
48
- fromDate,
49
- toDate,
50
- logLevel,
51
- filters,
52
- page,
53
- perPage
54
- }) {
55
- if (!transportId || !this.transports.has(transportId) || !runId) {
56
- return { logs: [], total: 0, page: page ?? 1, perPage: perPage ?? 100, hasMore: false };
57
- }
58
- return this.transports.get(transportId).listLogsByRunId?.({ runId, fromDate, toDate, logLevel, filters, page, perPage }) ?? {
59
- logs: [],
60
- total: 0,
61
- page: page ?? 1,
62
- perPage: perPage ?? 100,
63
- hasMore: false
64
- };
65
- }
38
+ name;
39
+ level;
40
+ transports;
41
+ constructor(options = {}) {
42
+ this.name = options.name || "Mastra";
43
+ this.level = options.level || LogLevel.ERROR;
44
+ this.transports = new Map(Object.entries(options.transports || {}));
45
+ }
46
+ getTransports() {
47
+ return this.transports;
48
+ }
49
+ trackException(_error, _metadata) {}
50
+ async listLogs(transportId, params) {
51
+ if (!transportId || !this.transports.has(transportId)) return {
52
+ logs: [],
53
+ total: 0,
54
+ page: params?.page ?? 1,
55
+ perPage: params?.perPage ?? 100,
56
+ hasMore: false
57
+ };
58
+ return this.transports.get(transportId).listLogs?.(params) ?? {
59
+ logs: [],
60
+ total: 0,
61
+ page: params?.page ?? 1,
62
+ perPage: params?.perPage ?? 100,
63
+ hasMore: false
64
+ };
65
+ }
66
+ async listLogsByRunId({ transportId, runId, fromDate, toDate, logLevel, filters, page, perPage }) {
67
+ if (!transportId || !this.transports.has(transportId) || !runId) return {
68
+ logs: [],
69
+ total: 0,
70
+ page: page ?? 1,
71
+ perPage: perPage ?? 100,
72
+ hasMore: false
73
+ };
74
+ return this.transports.get(transportId).listLogsByRunId?.({
75
+ runId,
76
+ fromDate,
77
+ toDate,
78
+ logLevel,
79
+ filters,
80
+ page,
81
+ perPage
82
+ }) ?? {
83
+ logs: [],
84
+ total: 0,
85
+ page: page ?? 1,
86
+ perPage: perPage ?? 100,
87
+ hasMore: false
88
+ };
89
+ }
66
90
  };
67
- var ConsoleLogger = class _ConsoleLogger extends MastraLogger {
68
- component;
69
- filter;
70
- constructor(options = {}) {
71
- super(options);
72
- this.component = options.component;
73
- this.filter = options.filter;
74
- }
75
- child(componentOrBindings) {
76
- const component = typeof componentOrBindings === "string" ? componentOrBindings : componentOrBindings?.component ?? this.component;
77
- return new _ConsoleLogger({
78
- name: this.name,
79
- level: this.level,
80
- component,
81
- filter: this.filter
82
- });
83
- }
84
- shouldLog(level, message, args) {
85
- if (!this.filter) return true;
86
- try {
87
- return this.filter({ component: this.component, level, message, args });
88
- } catch (e) {
89
- console.error(`[Logger] Filter error for component=${this.component} level=${level}:`, e);
90
- return true;
91
- }
92
- }
93
- prefix() {
94
- return this.component ? `[${this.component}] ` : "";
95
- }
96
- debug(message, ...args) {
97
- if (this.level === LogLevel.DEBUG && this.shouldLog(LogLevel.DEBUG, message, args)) {
98
- console.info(`${this.prefix()}${message}`, ...args);
99
- }
100
- }
101
- info(message, ...args) {
102
- if ((this.level === LogLevel.INFO || this.level === LogLevel.DEBUG) && this.shouldLog(LogLevel.INFO, message, args)) {
103
- console.info(`${this.prefix()}${message}`, ...args);
104
- }
105
- }
106
- warn(message, ...args) {
107
- if ((this.level === LogLevel.WARN || this.level === LogLevel.INFO || this.level === LogLevel.DEBUG) && this.shouldLog(LogLevel.WARN, message, args)) {
108
- console.warn(`${this.prefix()}${message}`, ...args);
109
- }
110
- }
111
- error(message, ...args) {
112
- if ((this.level === LogLevel.ERROR || this.level === LogLevel.WARN || this.level === LogLevel.INFO || this.level === LogLevel.DEBUG) && this.shouldLog(LogLevel.ERROR, message, args)) {
113
- console.error(`${this.prefix()}${message}`, ...args);
114
- }
115
- }
116
- async listLogs(_transportId, _params) {
117
- return { logs: [], total: 0, page: _params?.page ?? 1, perPage: _params?.perPage ?? 100, hasMore: false };
118
- }
119
- async listLogsByRunId(_args) {
120
- return { logs: [], total: 0, page: _args.page ?? 1, perPage: _args.perPage ?? 100, hasMore: false };
121
- }
91
+ var ConsoleLogger = class ConsoleLogger extends MastraLogger {
92
+ component;
93
+ filter;
94
+ constructor(options = {}) {
95
+ super(options);
96
+ this.component = options.component;
97
+ this.filter = options.filter;
98
+ }
99
+ child(componentOrBindings) {
100
+ const component = typeof componentOrBindings === "string" ? componentOrBindings : componentOrBindings?.component ?? this.component;
101
+ return new ConsoleLogger({
102
+ name: this.name,
103
+ level: this.level,
104
+ component,
105
+ filter: this.filter
106
+ });
107
+ }
108
+ shouldLog(level, message, args) {
109
+ if (!this.filter) return true;
110
+ try {
111
+ return this.filter({
112
+ component: this.component,
113
+ level,
114
+ message,
115
+ args
116
+ });
117
+ } catch (e) {
118
+ console.error(`[Logger] Filter error for component=${this.component} level=${level}:`, e);
119
+ return true;
120
+ }
121
+ }
122
+ prefix() {
123
+ return this.component ? `[${this.component}] ` : "";
124
+ }
125
+ debug(message, ...args) {
126
+ if (this.level === LogLevel.DEBUG && this.shouldLog(LogLevel.DEBUG, message, args)) console.info(`${this.prefix()}${message}`, ...args);
127
+ }
128
+ info(message, ...args) {
129
+ if ((this.level === LogLevel.INFO || this.level === LogLevel.DEBUG) && this.shouldLog(LogLevel.INFO, message, args)) console.info(`${this.prefix()}${message}`, ...args);
130
+ }
131
+ warn(message, ...args) {
132
+ if ((this.level === LogLevel.WARN || this.level === LogLevel.INFO || this.level === LogLevel.DEBUG) && this.shouldLog(LogLevel.WARN, message, args)) console.warn(`${this.prefix()}${message}`, ...args);
133
+ }
134
+ error(message, ...args) {
135
+ if ((this.level === LogLevel.ERROR || this.level === LogLevel.WARN || this.level === LogLevel.INFO || this.level === LogLevel.DEBUG) && this.shouldLog(LogLevel.ERROR, message, args)) console.error(`${this.prefix()}${message}`, ...args);
136
+ }
137
+ async listLogs(_transportId, _params) {
138
+ return {
139
+ logs: [],
140
+ total: 0,
141
+ page: _params?.page ?? 1,
142
+ perPage: _params?.perPage ?? 100,
143
+ hasMore: false
144
+ };
145
+ }
146
+ async listLogsByRunId(_args) {
147
+ return {
148
+ logs: [],
149
+ total: 0,
150
+ page: _args.page ?? 1,
151
+ perPage: _args.perPage ?? 100,
152
+ hasMore: false
153
+ };
154
+ }
122
155
  };
123
-
124
- // ../../packages/_internal-core/dist/base/index.js
156
+ //#endregion
157
+ //#region ../../packages/_internal-core/dist/base/index.js
125
158
  var MastraBase = class {
126
- component = RegisteredLogger.LLM;
127
- logger;
128
- name;
129
- #rawConfig;
130
- constructor({
131
- component,
132
- name,
133
- rawConfig
134
- }) {
135
- this.component = component || RegisteredLogger.LLM;
136
- this.name = name;
137
- this.#rawConfig = rawConfig;
138
- this.logger = new ConsoleLogger({ name: `${this.component} - ${this.name}` });
139
- }
140
- /**
141
- * Returns the raw storage configuration this primitive was created from,
142
- * or undefined if it was created from code.
143
- */
144
- toRawConfig() {
145
- return this.#rawConfig;
146
- }
147
- /**
148
- * Sets the raw storage configuration for this primitive.
149
- * @internal
150
- */
151
- __setRawConfig(rawConfig) {
152
- this.#rawConfig = rawConfig;
153
- }
154
- /**
155
- * Set the logger for the agent
156
- * @param logger
157
- */
158
- __setLogger(logger) {
159
- this.logger = "child" in logger && typeof logger.child === "function" ? logger.child({ component: this.component }) : logger;
160
- }
159
+ component = RegisteredLogger.LLM;
160
+ logger;
161
+ name;
162
+ #rawConfig;
163
+ constructor({ component, name, rawConfig }) {
164
+ this.component = component || RegisteredLogger.LLM;
165
+ this.name = name;
166
+ this.#rawConfig = rawConfig;
167
+ this.logger = new ConsoleLogger({ name: `${this.component} - ${this.name}` });
168
+ }
169
+ /**
170
+ * Returns the raw storage configuration this primitive was created from,
171
+ * or undefined if it was created from code.
172
+ */
173
+ toRawConfig() {
174
+ return this.#rawConfig;
175
+ }
176
+ /**
177
+ * Sets the raw storage configuration for this primitive.
178
+ * @internal
179
+ */
180
+ __setRawConfig(rawConfig) {
181
+ this.#rawConfig = rawConfig;
182
+ }
183
+ /**
184
+ * Set the logger for the agent
185
+ * @param logger
186
+ */
187
+ __setLogger(logger) {
188
+ this.logger = "child" in logger && typeof logger.child === "function" ? logger.child({ component: this.component }) : logger;
189
+ }
161
190
  };
162
-
163
- // ../../packages/_internals/voice/dist/chunk-NWNKSBZV.js
191
+ //#endregion
192
+ //#region ../../packages/_internals/voice/dist/aisdk-C_K-VCwq.js
164
193
  var MastraVoice = class extends MastraBase {
165
- listeningModel;
166
- speechModel;
167
- speaker;
168
- realtimeConfig;
169
- constructor({ listeningModel, speechModel, speaker, realtimeConfig, name } = {}) {
170
- super({
171
- component: "VOICE",
172
- name
173
- });
174
- this.listeningModel = listeningModel;
175
- this.speechModel = speechModel;
176
- this.speaker = speaker;
177
- this.realtimeConfig = realtimeConfig;
178
- }
179
- /**
180
- * Custom serialization for tracing/observability spans.
181
- * Excludes `apiKey` from listeningModel / speechModel / realtimeConfig
182
- * and any provider-specific state held by subclasses. Subclasses that
183
- * need to expose additional non-sensitive fields can override.
184
- */
185
- serializeForSpan() {
186
- return {
187
- component: "VOICE",
188
- name: this.name,
189
- speaker: this.speaker,
190
- listeningModel: this.listeningModel ? { name: this.listeningModel.name } : void 0,
191
- speechModel: this.speechModel ? { name: this.speechModel.name } : void 0,
192
- realtimeModel: this.realtimeConfig?.model
193
- };
194
- }
195
- updateConfig(_options) {
196
- this.logger.debug("updateConfig not implemented by this voice provider");
197
- }
198
- /**
199
- * Initializes a WebSocket or WebRTC connection for real-time communication
200
- * @returns Promise that resolves when the connection is established
201
- */
202
- async connect(_options) {
203
- this.logger.debug("connect not implemented by this voice provider");
204
- }
205
- /**
206
- * Relay audio data to the voice provider for real-time processing
207
- * @param audioData Audio data to relay
208
- */
209
- async send(_audioData) {
210
- this.logger.debug("relay not implemented by this voice provider");
211
- }
212
- /**
213
- * Trigger voice providers to respond
214
- */
215
- async answer(_options) {
216
- this.logger.debug("answer not implemented by this voice provider");
217
- }
218
- /**
219
- * Equip the voice provider with instructions
220
- * @param instructions Instructions to add
221
- */
222
- addInstructions(_instructions) {
223
- }
224
- /**
225
- * Equip the voice provider with tools
226
- * @param tools Array of tools to add
227
- */
228
- addTools(_tools) {
229
- }
230
- /**
231
- * Disconnect from the WebSocket or WebRTC connection
232
- */
233
- close() {
234
- this.logger.debug("close not implemented by this voice provider");
235
- }
236
- /**
237
- * Register an event listener
238
- * @param event Event name (e.g., 'speaking', 'writing', 'error')
239
- * @param callback Callback function that receives event data
240
- */
241
- on(_event, _callback) {
242
- this.logger.debug("on not implemented by this voice provider");
243
- }
244
- /**
245
- * Remove an event listener
246
- * @param event Event name (e.g., 'speaking', 'writing', 'error')
247
- * @param callback Callback function to remove
248
- */
249
- off(_event, _callback) {
250
- this.logger.debug("off not implemented by this voice provider");
251
- }
252
- /**
253
- * Get available speakers/voices
254
- * @returns Array of available voice IDs and their metadata
255
- */
256
- getSpeakers() {
257
- this.logger.debug("getSpeakers not implemented by this voice provider");
258
- return Promise.resolve([]);
259
- }
260
- /**
261
- * Get available speakers/voices
262
- * @returns Array of available voice IDs and their metadata
263
- */
264
- getListener() {
265
- this.logger.debug("getListener not implemented by this voice provider");
266
- return Promise.resolve({ enabled: false });
267
- }
194
+ listeningModel;
195
+ speechModel;
196
+ speaker;
197
+ realtimeConfig;
198
+ constructor({ listeningModel, speechModel, speaker, realtimeConfig, name } = {}) {
199
+ super({
200
+ component: "VOICE",
201
+ name
202
+ });
203
+ this.listeningModel = listeningModel;
204
+ this.speechModel = speechModel;
205
+ this.speaker = speaker;
206
+ this.realtimeConfig = realtimeConfig;
207
+ }
208
+ /**
209
+ * Custom serialization for tracing/observability spans.
210
+ * Excludes `apiKey` from listeningModel / speechModel / realtimeConfig
211
+ * and any provider-specific state held by subclasses. Subclasses that
212
+ * need to expose additional non-sensitive fields can override.
213
+ */
214
+ serializeForSpan() {
215
+ return {
216
+ component: "VOICE",
217
+ name: this.name,
218
+ speaker: this.speaker,
219
+ listeningModel: this.listeningModel ? { name: this.listeningModel.name } : void 0,
220
+ speechModel: this.speechModel ? { name: this.speechModel.name } : void 0,
221
+ realtimeModel: this.realtimeConfig?.model
222
+ };
223
+ }
224
+ updateConfig(_options) {
225
+ this.logger.debug("updateConfig not implemented by this voice provider");
226
+ }
227
+ /**
228
+ * Initializes a WebSocket or WebRTC connection for real-time communication
229
+ * @returns Promise that resolves when the connection is established
230
+ */
231
+ async connect(_options) {
232
+ this.logger.debug("connect not implemented by this voice provider");
233
+ }
234
+ /**
235
+ * Relay audio data to the voice provider for real-time processing
236
+ * @param audioData Audio data to relay
237
+ */
238
+ async send(_audioData) {
239
+ this.logger.debug("relay not implemented by this voice provider");
240
+ }
241
+ /**
242
+ * Trigger voice providers to respond
243
+ */
244
+ async answer(_options) {
245
+ this.logger.debug("answer not implemented by this voice provider");
246
+ }
247
+ /**
248
+ * Equip the voice provider with instructions
249
+ * @param instructions Instructions to add
250
+ */
251
+ addInstructions(_instructions) {}
252
+ /**
253
+ * Equip the voice provider with tools
254
+ * @param tools Array of tools to add
255
+ */
256
+ addTools(_tools) {}
257
+ /**
258
+ * Disconnect from the WebSocket or WebRTC connection
259
+ */
260
+ close() {
261
+ this.logger.debug("close not implemented by this voice provider");
262
+ }
263
+ /**
264
+ * Register an event listener
265
+ * @param event Event name (e.g., 'speaking', 'writing', 'error')
266
+ * @param callback Callback function that receives event data
267
+ */
268
+ on(_event, _callback) {
269
+ this.logger.debug("on not implemented by this voice provider");
270
+ }
271
+ /**
272
+ * Remove an event listener
273
+ * @param event Event name (e.g., 'speaking', 'writing', 'error')
274
+ * @param callback Callback function to remove
275
+ */
276
+ off(_event, _callback) {
277
+ this.logger.debug("off not implemented by this voice provider");
278
+ }
279
+ /**
280
+ * Get available speakers/voices
281
+ * @returns Array of available voice IDs and their metadata
282
+ */
283
+ getSpeakers() {
284
+ this.logger.debug("getSpeakers not implemented by this voice provider");
285
+ return Promise.resolve([]);
286
+ }
287
+ /**
288
+ * Get available speakers/voices
289
+ * @returns Array of available voice IDs and their metadata
290
+ */
291
+ getListener() {
292
+ this.logger.debug("getListener not implemented by this voice provider");
293
+ return Promise.resolve({ enabled: false });
294
+ }
268
295
  };
269
-
270
- // src/types.ts
271
- var NovaSonicErrorCode = /* @__PURE__ */ ((NovaSonicErrorCode2) => {
272
- NovaSonicErrorCode2["CONNECTION_FAILED"] = "connection_failed";
273
- NovaSonicErrorCode2["CONNECTION_NOT_ESTABLISHED"] = "connection_not_established";
274
- NovaSonicErrorCode2["AUTHENTICATION_FAILED"] = "authentication_failed";
275
- NovaSonicErrorCode2["CREDENTIALS_MISSING"] = "credentials_missing";
276
- NovaSonicErrorCode2["REGION_INVALID"] = "region_invalid";
277
- NovaSonicErrorCode2["WEBSOCKET_ERROR"] = "websocket_error";
278
- NovaSonicErrorCode2["AUDIO_PROCESSING_ERROR"] = "audio_processing_error";
279
- NovaSonicErrorCode2["AUDIO_STREAM_ERROR"] = "audio_stream_error";
280
- NovaSonicErrorCode2["SPEAKER_STREAM_ERROR"] = "speaker_stream_error";
281
- NovaSonicErrorCode2["TRANSCRIPTION_TIMEOUT"] = "transcription_timeout";
282
- NovaSonicErrorCode2["TRANSCRIPTION_FAILED"] = "transcription_failed";
283
- NovaSonicErrorCode2["TOOL_EXECUTION_ERROR"] = "tool_execution_error";
284
- NovaSonicErrorCode2["TOOL_NOT_FOUND"] = "tool_not_found";
285
- NovaSonicErrorCode2["SESSION_CONFIG_UPDATE_FAILED"] = "session_config_update_failed";
286
- NovaSonicErrorCode2["INVALID_AUDIO_FORMAT"] = "invalid_audio_format";
287
- NovaSonicErrorCode2["NOT_CONNECTED"] = "not_connected";
288
- NovaSonicErrorCode2["INVALID_STATE"] = "invalid_state";
289
- NovaSonicErrorCode2["VALIDATION_ERROR"] = "validation_error";
290
- NovaSonicErrorCode2["UNKNOWN_ERROR"] = "unknown_error";
291
- return NovaSonicErrorCode2;
292
- })(NovaSonicErrorCode || {});
293
-
294
- // src/utils/errors.ts
296
+ //#endregion
297
+ //#region src/types.ts
298
+ /**
299
+ * Error codes for Nova Sonic specific errors
300
+ */
301
+ let NovaSonicErrorCode = /* @__PURE__ */ function(NovaSonicErrorCode) {
302
+ NovaSonicErrorCode["CONNECTION_FAILED"] = "connection_failed";
303
+ NovaSonicErrorCode["CONNECTION_NOT_ESTABLISHED"] = "connection_not_established";
304
+ NovaSonicErrorCode["AUTHENTICATION_FAILED"] = "authentication_failed";
305
+ NovaSonicErrorCode["CREDENTIALS_MISSING"] = "credentials_missing";
306
+ NovaSonicErrorCode["REGION_INVALID"] = "region_invalid";
307
+ NovaSonicErrorCode["WEBSOCKET_ERROR"] = "websocket_error";
308
+ NovaSonicErrorCode["AUDIO_PROCESSING_ERROR"] = "audio_processing_error";
309
+ NovaSonicErrorCode["AUDIO_STREAM_ERROR"] = "audio_stream_error";
310
+ NovaSonicErrorCode["SPEAKER_STREAM_ERROR"] = "speaker_stream_error";
311
+ NovaSonicErrorCode["TRANSCRIPTION_TIMEOUT"] = "transcription_timeout";
312
+ NovaSonicErrorCode["TRANSCRIPTION_FAILED"] = "transcription_failed";
313
+ NovaSonicErrorCode["TOOL_EXECUTION_ERROR"] = "tool_execution_error";
314
+ NovaSonicErrorCode["TOOL_NOT_FOUND"] = "tool_not_found";
315
+ NovaSonicErrorCode["SESSION_CONFIG_UPDATE_FAILED"] = "session_config_update_failed";
316
+ NovaSonicErrorCode["INVALID_AUDIO_FORMAT"] = "invalid_audio_format";
317
+ NovaSonicErrorCode["NOT_CONNECTED"] = "not_connected";
318
+ NovaSonicErrorCode["INVALID_STATE"] = "invalid_state";
319
+ NovaSonicErrorCode["VALIDATION_ERROR"] = "validation_error";
320
+ NovaSonicErrorCode["UNKNOWN_ERROR"] = "unknown_error";
321
+ return NovaSonicErrorCode;
322
+ }({});
323
+ //#endregion
324
+ //#region src/utils/errors.ts
325
+ /**
326
+ * Helper class for consistent error handling across Nova Sonic voice integration
327
+ */
295
328
  var NovaSonicError = class extends Error {
296
- code;
297
- details;
298
- timestamp;
299
- constructor(code, message, details) {
300
- super(message);
301
- this.name = "NovaSonicError";
302
- this.code = code;
303
- this.details = details;
304
- this.timestamp = Date.now();
305
- }
306
- toEventData() {
307
- return {
308
- message: this.message,
309
- code: this.code,
310
- details: this.details,
311
- timestamp: this.timestamp
312
- };
313
- }
329
+ code;
330
+ details;
331
+ timestamp;
332
+ constructor(code, message, details) {
333
+ super(message);
334
+ this.name = "NovaSonicError";
335
+ this.code = code;
336
+ this.details = details;
337
+ this.timestamp = Date.now();
338
+ }
339
+ toEventData() {
340
+ return {
341
+ message: this.message,
342
+ code: this.code,
343
+ details: this.details,
344
+ timestamp: this.timestamp
345
+ };
346
+ }
314
347
  };
315
-
316
- // src/utils/auth.ts
348
+ //#endregion
349
+ //#region src/utils/auth.ts
350
+ /**
351
+ * Get AWS credentials from various sources
352
+ */
317
353
  async function getAwsCredentials(explicitCredentials, debug) {
318
- if (explicitCredentials) {
319
- if (debug) {
320
- console.info("[getAwsCredentials] Using explicit credentials provided in config");
321
- }
322
- return explicitCredentials;
323
- }
324
- try {
325
- if (debug) {
326
- console.info("[getAwsCredentials] Using default credential provider chain");
327
- }
328
- const credentials = await credentialProviderNode.defaultProvider()();
329
- if (debug) {
330
- console.info("[getAwsCredentials] Credentials retrieved successfully");
331
- }
332
- return credentials;
333
- } catch (error) {
334
- if (error instanceof NovaSonicError) {
335
- throw error;
336
- }
337
- throw new NovaSonicError(
338
- "authentication_failed" /* AUTHENTICATION_FAILED */,
339
- `Failed to load AWS credentials: ${error instanceof Error ? error.message : "Unknown error"}`,
340
- error
341
- );
342
- }
354
+ if (explicitCredentials) {
355
+ if (debug) console.info("[getAwsCredentials] Using explicit credentials provided in config");
356
+ return explicitCredentials;
357
+ }
358
+ try {
359
+ if (debug) console.info("[getAwsCredentials] Using default credential provider chain");
360
+ const credentials = await (0, _aws_sdk_credential_provider_node.defaultProvider)()();
361
+ if (debug) console.info("[getAwsCredentials] Credentials retrieved successfully");
362
+ return credentials;
363
+ } catch (error) {
364
+ if (error instanceof NovaSonicError) throw error;
365
+ throw new NovaSonicError("authentication_failed", `Failed to load AWS credentials: ${error instanceof Error ? error.message : "Unknown error"}`, error);
366
+ }
343
367
  }
344
-
345
- // src/index.ts
346
- var DEFAULT_MODEL = "amazon.nova-2-sonic-v1:0";
347
- var DEFAULT_REGION = "us-east-1";
368
+ //#endregion
369
+ //#region src/index.ts
370
+ /**
371
+ * Default configuration values
372
+ */
373
+ const DEFAULT_MODEL = "amazon.nova-2-sonic-v1:0";
374
+ const DEFAULT_REGION = "us-east-1";
375
+ /**
376
+ * NovaSonicVoice provides real-time voice interaction capabilities using AWS Nova 2 Sonic's
377
+ * bidirectional streaming API. It supports:
378
+ * - Real-time text-to-speech
379
+ * - Speech-to-text (transcription)
380
+ * - Voice activity detection
381
+ * - Multiple voice options and languages
382
+ * - Polyglot voices
383
+ * - Tool/function calling
384
+ * - Cross-modal input (audio and text)
385
+ *
386
+ * The class manages HTTP/2 bidirectional streaming connections, audio streaming, and event handling
387
+ * for seamless voice interactions.
388
+ *
389
+ * @extends MastraVoice
390
+ *
391
+ * @example
392
+ * ```typescript
393
+ * const voice = new NovaSonicVoice({
394
+ * region: 'us-east-1',
395
+ * model: 'amazon.nova-2-sonic-v1:0',
396
+ * speaker: 'default',
397
+ * });
398
+ *
399
+ * await voice.connect();
400
+ * voice.on('speaking', ({ audio }) => {
401
+ * // Handle audio data
402
+ * });
403
+ *
404
+ * await voice.speak('Hello, how can I help you today?');
405
+ * ```
406
+ */
348
407
  var NovaSonicVoice = class extends MastraVoice {
349
- client;
350
- stream;
351
- inputStream;
352
- // Input stream for sending events to AWS
353
- _eventQueue;
354
- _signalQueue;
355
- _closeSignal;
356
- _promptName;
357
- state = "disconnected";
358
- events;
359
- instructions;
360
- tools;
361
- requestContext;
362
- debug;
363
- region;
364
- model;
365
- credentials;
366
- speakerStreams;
367
- currentResponseId;
368
- processingStream = false;
369
- streamRestartAttempted = false;
370
- // Prevent multiple restart attempts
371
- sessionConfig;
372
- promptStarted = false;
373
- // Track if promptStart was sent (now sent during connection)
374
- audioContentName;
375
- audioContentStarted = false;
376
- hasSentContentEnd = false;
377
- // Track if contentEnd has been sent for current turn
378
- turnCompleted = false;
379
- // Track if turn has been completed (to prevent sending contentEnd after turn completion)
380
- turnCompleteTimeout;
381
- // Timeout for fallback turn completion
382
- isReceivingAssistantAudio = false;
383
- // Track if we're currently receiving assistant audio output
384
- currentTextGenerationStage;
385
- // Track generationStage (SPECULATIVE|FINAL) for current text content block
386
- /**
387
- * Creates a new instance of NovaSonicVoice.
388
- *
389
- * @param config - Configuration options for the voice instance
390
- * @param config.region - AWS region (defaults to us-east-1)
391
- * @param config.model - The model ID to use (defaults to amazon.nova-2-sonic-v1:0)
392
- * @param config.credentials - AWS credentials (optional, uses default credential chain)
393
- * @param config.speaker - Voice name/identifier
394
- * @param config.languageCode - Language code for the voice
395
- * @param config.debug - Enable debug mode
396
- *
397
- * @example
398
- * ```typescript
399
- * const voice = new NovaSonicVoice({
400
- * region: 'us-east-1',
401
- * model: 'amazon.nova-2-sonic-v1:0',
402
- * speaker: 'default',
403
- * });
404
- * ```
405
- */
406
- constructor(config = {}) {
407
- let normalizedConfig;
408
- if ("realtimeConfig" in config || "speechModel" in config || "listeningModel" in config) {
409
- normalizedConfig = config;
410
- } else {
411
- const configOptions = config;
412
- normalizedConfig = {
413
- realtimeConfig: {
414
- model: configOptions.model || DEFAULT_MODEL,
415
- apiKey: void 0,
416
- // AWS doesn't use API keys
417
- options: configOptions
418
- },
419
- speaker: typeof configOptions.speaker === "string" ? configOptions.speaker : "matthew"
420
- };
421
- }
422
- super(normalizedConfig);
423
- const options = normalizedConfig.realtimeConfig?.options || config;
424
- this.region = options.region || DEFAULT_REGION;
425
- this.model = options.model || DEFAULT_MODEL;
426
- this.credentials = options.credentials;
427
- this.debug = options.debug || false;
428
- this.sessionConfig = options.sessionConfig;
429
- this.events = {};
430
- this.speakerStreams = /* @__PURE__ */ new Map();
431
- const validRegions = ["us-east-1", "us-west-2", "ap-northeast-1"];
432
- if (!validRegions.includes(this.region)) {
433
- throw new NovaSonicError(
434
- "region_invalid" /* REGION_INVALID */,
435
- `Invalid region: ${this.region}. Supported regions: ${validRegions.join(", ")}`
436
- );
437
- }
438
- }
439
- /**
440
- * Returns a list of available voice speakers.
441
- *
442
- * Nova 2 Sonic provides expressive voices across multiple languages.
443
- * Tiffany (en-US, feminine) and Matthew (en-US, masculine) are polyglot
444
- * voices that can speak all supported languages.
445
- *
446
- * @returns Promise resolving to an array of voice objects
447
- */
448
- async getSpeakers() {
449
- return Promise.resolve([
450
- // English (US) - Polyglot voices
451
- { voiceId: "tiffany", name: "Tiffany", language: "English", locale: "en-US", gender: "feminine", polyglot: true },
452
- {
453
- voiceId: "matthew",
454
- name: "Matthew",
455
- language: "English",
456
- locale: "en-US",
457
- gender: "masculine",
458
- polyglot: true
459
- },
460
- // English (UK)
461
- { voiceId: "amy", name: "Amy", language: "English", locale: "en-GB", gender: "feminine", polyglot: false },
462
- // English (Australia)
463
- { voiceId: "olivia", name: "Olivia", language: "English", locale: "en-AU", gender: "feminine", polyglot: false },
464
- // English (Indian)
465
- { voiceId: "kiara", name: "Kiara", language: "English", locale: "en-IN", gender: "feminine", polyglot: false },
466
- { voiceId: "arjun", name: "Arjun", language: "English", locale: "en-IN", gender: "masculine", polyglot: false },
467
- // French
468
- { voiceId: "ambre", name: "Ambre", language: "French", locale: "fr-FR", gender: "feminine", polyglot: false },
469
- {
470
- voiceId: "florian",
471
- name: "Florian",
472
- language: "French",
473
- locale: "fr-FR",
474
- gender: "masculine",
475
- polyglot: false
476
- },
477
- // Italian
478
- {
479
- voiceId: "beatrice",
480
- name: "Beatrice",
481
- language: "Italian",
482
- locale: "it-IT",
483
- gender: "feminine",
484
- polyglot: false
485
- },
486
- {
487
- voiceId: "lorenzo",
488
- name: "Lorenzo",
489
- language: "Italian",
490
- locale: "it-IT",
491
- gender: "masculine",
492
- polyglot: false
493
- },
494
- // German
495
- { voiceId: "tina", name: "Tina", language: "German", locale: "de-DE", gender: "feminine", polyglot: false },
496
- {
497
- voiceId: "lennart",
498
- name: "Lennart",
499
- language: "German",
500
- locale: "de-DE",
501
- gender: "masculine",
502
- polyglot: false
503
- },
504
- // Spanish (US)
505
- { voiceId: "lupe", name: "Lupe", language: "Spanish", locale: "es-US", gender: "feminine", polyglot: false },
506
- { voiceId: "carlos", name: "Carlos", language: "Spanish", locale: "es-US", gender: "masculine", polyglot: false },
507
- // Portuguese
508
- {
509
- voiceId: "carolina",
510
- name: "Carolina",
511
- language: "Portuguese",
512
- locale: "pt-BR",
513
- gender: "feminine",
514
- polyglot: false
515
- },
516
- { voiceId: "leo", name: "Leo", language: "Portuguese", locale: "pt-BR", gender: "masculine", polyglot: false },
517
- // Hindi
518
- { voiceId: "kiara", name: "Kiara", language: "Hindi", locale: "hi-IN", gender: "feminine", polyglot: false },
519
- { voiceId: "arjun", name: "Arjun", language: "Hindi", locale: "hi-IN", gender: "masculine", polyglot: false }
520
- ]);
521
- }
522
- /**
523
- * Establishes a connection to the AWS Bedrock bidirectional streaming service.
524
- * Must be called before using speak, listen, or send functions.
525
- *
526
- * @throws {NovaSonicError} If connection fails or credentials are missing
527
- *
528
- * @example
529
- * ```typescript
530
- * await voice.connect();
531
- * // Now ready for voice interactions
532
- * ```
533
- */
534
- async connect({ requestContext } = {}) {
535
- if (this.state === "connected" || this.state === "connecting") {
536
- this.log("Already connected or connecting");
537
- return;
538
- }
539
- this.state = "connecting";
540
- this.requestContext = requestContext;
541
- this.streamRestartAttempted = false;
542
- try {
543
- await this.createBedrockClient();
544
- const asyncIterable = this.createEventQueue();
545
- this.enqueueInitialSessionEvents();
546
- await this.sendInitialConnectCommand(asyncIterable);
547
- this.processStream().catch((error) => {
548
- this.log("Error in stream processing:", error);
549
- this.emit("error", {
550
- message: error instanceof Error ? error.message : "Stream processing error",
551
- code: "STREAM_PROCESSING_ERROR",
552
- details: error
553
- });
554
- });
555
- this.log("Connected to AWS Bedrock Nova 2 Sonic");
556
- } catch (error) {
557
- this.state = "disconnected";
558
- if (this.client) {
559
- if (typeof this.client.destroy === "function") {
560
- this.client.destroy();
561
- }
562
- this.client = void 0;
563
- }
564
- this.log("Connection error:", error);
565
- const errorMessage = error instanceof Error ? error.message : "Unknown error during connection";
566
- throw new NovaSonicError("connection_failed" /* CONNECTION_FAILED */, `Failed to connect to AWS Bedrock: ${errorMessage}`, error);
567
- }
568
- }
569
- /**
570
- * Resolve credentials and initialize the Bedrock Runtime client over HTTP/2.
571
- */
572
- async createBedrockClient() {
573
- this.log("Getting AWS credentials...");
574
- const credentials = await getAwsCredentials(this.credentials, this.debug);
575
- if (!credentials) {
576
- throw new NovaSonicError(
577
- "credentials_missing" /* CREDENTIALS_MISSING */,
578
- "AWS credentials are required. Please configure AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables or provide credentials in the config."
579
- );
580
- }
581
- this.log("Credentials retrieved:", {
582
- hasAccessKeyId: !!credentials.accessKeyId,
583
- hasSecretAccessKey: !!credentials.secretAccessKey,
584
- hasSessionToken: !!credentials.sessionToken,
585
- accessKeyIdPrefix: credentials.accessKeyId ? `${credentials.accessKeyId.substring(0, 6)}...` : "missing",
586
- expiration: credentials.expiration ? credentials.expiration.toISOString() : "no expiration"
587
- });
588
- this.log(`Initializing Bedrock Runtime client for region: ${this.region}, model: ${this.model}`);
589
- const nodeHttp2Handler = new nodeHttpHandler.NodeHttp2Handler({
590
- requestTimeout: 3e5,
591
- // 5 minutes
592
- sessionTimeout: 3e5,
593
- // 5 minutes
594
- disableConcurrentStreams: false,
595
- maxConcurrentStreams: 20
596
- });
597
- this.client = new clientBedrockRuntime.BedrockRuntimeClient({
598
- region: this.region,
599
- credentials,
600
- requestHandler: nodeHttp2Handler
601
- });
602
- }
603
- /**
604
- * Build the async-iterable event queue used as the request body for the
605
- * bidirectional stream. Returns the iterable and wires up internal queue
606
- * helpers (_eventQueue, _signalQueue, _closeSignal) used by sendClientEvent.
607
- */
608
- createEventQueue() {
609
- this.log("Creating bidirectional stream command...");
610
- const voiceInstance = this;
611
- const eventQueue = [];
612
- const pendingResolvers = [];
613
- let closeSignal = false;
614
- const signalQueue = () => {
615
- if (pendingResolvers.length > 0) {
616
- voiceInstance.log(`[AsyncIterable] Signaling queue - resolving ${pendingResolvers.length} pending Promise(s)`);
617
- const resolvers = [...pendingResolvers];
618
- pendingResolvers.length = 0;
619
- resolvers.forEach((resolve) => resolve());
620
- } else {
621
- voiceInstance.log("[AsyncIterable] signalQueue called but no pending Promise");
622
- }
623
- };
624
- const asyncIterable = {
625
- [Symbol.asyncIterator]: () => {
626
- voiceInstance.log("[AsyncIterable] Iterator requested");
627
- return {
628
- next: async () => {
629
- try {
630
- if (closeSignal || voiceInstance.state === "disconnected") {
631
- voiceInstance.log(`[AsyncIterable] Stream closed (state: ${voiceInstance.state}), done = true`);
632
- return { value: void 0, done: true };
633
- }
634
- if (eventQueue.length === 0) {
635
- try {
636
- voiceInstance.log("[AsyncIterable] Queue empty, waiting for signal...");
637
- await new Promise((resolve) => {
638
- pendingResolvers.push(resolve);
639
- voiceInstance.log(
640
- `[AsyncIterable] Promise created, waiting for signal (${pendingResolvers.length} pending)...`
641
- );
642
- setImmediate(() => {
643
- if (eventQueue.length > 0) {
644
- voiceInstance.log("[AsyncIterable] Data arrived before wait, resolving immediately");
645
- const index = pendingResolvers.indexOf(resolve);
646
- if (index !== -1) {
647
- pendingResolvers.splice(index, 1);
648
- resolve();
649
- }
650
- return;
651
- }
652
- if (closeSignal || voiceInstance.state === "disconnected") {
653
- voiceInstance.log("[AsyncIterable] Closed before wait, resolving");
654
- const index = pendingResolvers.indexOf(resolve);
655
- if (index !== -1) {
656
- pendingResolvers.splice(index, 1);
657
- resolve();
658
- }
659
- return;
660
- }
661
- });
662
- });
663
- voiceInstance.log("[AsyncIterable] Promise resolved, checking queue...");
664
- } catch (error) {
665
- if (error instanceof Error && error.message === "Stream closed") {
666
- voiceInstance.log("[AsyncIterable] Stream closed during wait");
667
- return { value: void 0, done: true };
668
- }
669
- voiceInstance.log("[AsyncIterable] Error during wait:", error);
670
- }
671
- }
672
- if (closeSignal) {
673
- voiceInstance.log("[AsyncIterable] Stream closed (closeSignal)");
674
- return { value: void 0, done: true };
675
- }
676
- if (voiceInstance.state === "disconnected") {
677
- voiceInstance.log("[AsyncIterable] Stream closed (disconnected state)");
678
- return { value: void 0, done: true };
679
- }
680
- while (eventQueue.length === 0 && !closeSignal) {
681
- if (voiceInstance.state === "disconnected") {
682
- voiceInstance.log("[AsyncIterable] Stream closed before wait loop");
683
- return { value: void 0, done: true };
684
- }
685
- voiceInstance.log("[AsyncIterable] Queue still empty, waiting again...");
686
- await new Promise((resolve) => {
687
- pendingResolvers.push(resolve);
688
- setImmediate(() => {
689
- if (eventQueue.length > 0 || closeSignal || voiceInstance.state === "disconnected") {
690
- const index = pendingResolvers.indexOf(resolve);
691
- if (index !== -1) {
692
- pendingResolvers.splice(index, 1);
693
- resolve();
694
- }
695
- }
696
- });
697
- });
698
- if (closeSignal || voiceInstance.state === "disconnected") {
699
- voiceInstance.log("[AsyncIterable] Stream closed during wait loop");
700
- return { value: void 0, done: true };
701
- }
702
- }
703
- const nextEvent = eventQueue.shift();
704
- const eventJson = JSON.stringify(nextEvent);
705
- const eventBytes = Buffer.from(eventJson, "utf-8");
706
- voiceInstance.log(`[AsyncIterable] Yielding event of size: ${eventBytes.length}`);
707
- return {
708
- value: {
709
- chunk: {
710
- bytes: eventBytes
711
- }
712
- },
713
- done: false
714
- };
715
- } catch (error) {
716
- voiceInstance.log("[AsyncIterable] Error in iterator:", error);
717
- closeSignal = true;
718
- return { value: void 0, done: true };
719
- }
720
- },
721
- return: async () => {
722
- voiceInstance.log("[AsyncIterable] Iterator return() called");
723
- closeSignal = true;
724
- signalQueue();
725
- return { value: void 0, done: true };
726
- },
727
- throw: async (error) => {
728
- voiceInstance.log("[AsyncIterable] Iterator throw() called:", error);
729
- closeSignal = true;
730
- signalQueue();
731
- throw error;
732
- }
733
- };
734
- }
735
- };
736
- this._eventQueue = eventQueue;
737
- this._signalQueue = signalQueue;
738
- this._closeSignal = () => {
739
- closeSignal = true;
740
- signalQueue();
741
- };
742
- return asyncIterable;
743
- }
744
- /**
745
- * Pre-populate the event queue with the AWS Nova Sonic connection
746
- * handshake events: sessionStart, promptStart, then a SYSTEM text content
747
- * block carrying the configured instructions. AUDIO contentStart is NOT
748
- * sent here; it is deferred to the first send() call.
749
- */
750
- enqueueInitialSessionEvents() {
751
- const eventQueue = this._eventQueue;
752
- if (!eventQueue) {
753
- throw new NovaSonicError(
754
- "connection_failed" /* CONNECTION_FAILED */,
755
- "Event queue must be initialized before enqueueing session events"
756
- );
757
- }
758
- this.log("Pre-populating queue with sessionStart and promptStart events...");
759
- const promptName = crypto.randomUUID();
760
- this._promptName = promptName;
761
- const sessionStartEvent = {};
762
- if (this.sessionConfig) {
763
- if (this.sessionConfig.inferenceConfiguration) {
764
- sessionStartEvent.inferenceConfiguration = {
765
- maxTokens: this.sessionConfig.inferenceConfiguration.maxTokens || 4096,
766
- topP: this.sessionConfig.inferenceConfiguration.topP || 0.9,
767
- temperature: this.sessionConfig.inferenceConfiguration.temperature || 0.7,
768
- ...this.sessionConfig.inferenceConfiguration.topK !== void 0 && {
769
- topK: this.sessionConfig.inferenceConfiguration.topK
770
- },
771
- ...this.sessionConfig.inferenceConfiguration.stopSequences && {
772
- stopSequences: this.sessionConfig.inferenceConfiguration.stopSequences
773
- }
774
- };
775
- } else {
776
- sessionStartEvent.inferenceConfiguration = {
777
- maxTokens: 4096,
778
- topP: 0.9,
779
- temperature: 0.7
780
- };
781
- }
782
- if (this.sessionConfig.turnDetectionConfiguration) {
783
- sessionStartEvent.turnDetectionConfiguration = {
784
- ...this.sessionConfig.turnDetectionConfiguration.endpointingSensitivity && {
785
- endpointingSensitivity: this.sessionConfig.turnDetectionConfiguration.endpointingSensitivity
786
- }
787
- };
788
- }
789
- } else {
790
- sessionStartEvent.inferenceConfiguration = {
791
- maxTokens: 4096,
792
- topP: 0.9,
793
- temperature: 0.7
794
- };
795
- }
796
- eventQueue.push({
797
- event: {
798
- sessionStart: sessionStartEvent
799
- }
800
- });
801
- let voiceId = "matthew";
802
- if (this.sessionConfig?.voice) {
803
- if (typeof this.sessionConfig.voice === "string") {
804
- voiceId = this.sessionConfig.voice;
805
- } else if (this.sessionConfig.voice.name) {
806
- voiceId = this.sessionConfig.voice.name;
807
- }
808
- } else if (this.speaker && this.speaker !== "default") {
809
- if (typeof this.speaker === "string") {
810
- voiceId = this.speaker;
811
- } else {
812
- const speakerObj = this.speaker;
813
- if (speakerObj && typeof speakerObj === "object" && speakerObj.name) {
814
- voiceId = speakerObj.name;
815
- }
816
- }
817
- }
818
- const promptStartEvent = {
819
- promptName,
820
- textOutputConfiguration: {
821
- mediaType: "text/plain"
822
- },
823
- // AWS REQUIRES this - cannot be omitted
824
- audioOutputConfiguration: {
825
- mediaType: "audio/lpcm",
826
- sampleRateHertz: 24e3,
827
- sampleSizeBits: 16,
828
- channelCount: 1,
829
- voiceId,
830
- encoding: "base64",
831
- audioType: "SPEECH"
832
- }
833
- };
834
- if (this.sessionConfig?.tools && this.sessionConfig.tools.length > 0) {
835
- promptStartEvent.toolConfiguration = {
836
- tools: this.sessionConfig.tools.map((tool) => {
837
- let inputSchemaJson;
838
- if (typeof tool.inputSchema === "string") {
839
- inputSchemaJson = tool.inputSchema;
840
- } else {
841
- inputSchemaJson = JSON.stringify(tool.inputSchema);
842
- }
843
- return {
844
- toolSpec: {
845
- name: tool.name,
846
- description: tool.description,
847
- inputSchema: {
848
- json: inputSchemaJson
849
- }
850
- }
851
- };
852
- }),
853
- // toolChoice goes inside toolConfiguration for Nova 2 Sonic
854
- ...this.sessionConfig?.toolChoice && { toolChoice: this.sessionConfig.toolChoice }
855
- };
856
- } else if (this.sessionConfig?.toolChoice) {
857
- promptStartEvent.toolConfiguration = {
858
- toolChoice: this.sessionConfig.toolChoice
859
- };
860
- }
861
- eventQueue.push({
862
- event: {
863
- promptStart: promptStartEvent
864
- }
865
- });
866
- this.promptStarted = true;
867
- const systemContentName = crypto.randomUUID();
868
- eventQueue.push({
869
- event: {
870
- contentStart: {
871
- promptName,
872
- contentName: systemContentName,
873
- type: "TEXT",
874
- interactive: false,
875
- role: "SYSTEM",
876
- textInputConfiguration: {
877
- mediaType: "text/plain"
878
- }
879
- }
880
- }
881
- });
882
- eventQueue.push({
883
- event: {
884
- textInput: {
885
- promptName,
886
- contentName: systemContentName,
887
- content: this.instructions || ""
888
- }
889
- }
890
- });
891
- eventQueue.push({
892
- event: {
893
- contentEnd: {
894
- promptName,
895
- contentName: systemContentName
896
- }
897
- }
898
- });
899
- this.audioContentStarted = false;
900
- this.log(`Queue pre-populated with ${eventQueue.length} event(s)`);
901
- }
902
- /**
903
- * Issue the InvokeModelWithBidirectionalStreamCommand to AWS Bedrock with
904
- * a 5-second abort timeout that tears down the client on hang to avoid
905
- * leaked HTTP/2 sessions. On success the response stream is stored and the
906
- * voice transitions to 'connected'.
907
- */
908
- async sendInitialConnectCommand(asyncIterable) {
909
- if (!this.client) {
910
- throw new NovaSonicError(
911
- "connection_failed" /* CONNECTION_FAILED */,
912
- "Bedrock client must be created before sending the initial command"
913
- );
914
- }
915
- const command = new clientBedrockRuntime.InvokeModelWithBidirectionalStreamCommand({
916
- modelId: this.model,
917
- body: asyncIterable
918
- // Type assertion needed as SDK types may be strict
919
- });
920
- const sendStartTime = Date.now();
921
- const abortController = new AbortController();
922
- const timeoutId = setTimeout(() => {
923
- this.log("[DEBUG] client.send() timeout after 5 seconds - aborting request");
924
- abortController.abort();
925
- }, 5e3);
926
- let response;
927
- try {
928
- response = await this.client.send(command, { abortSignal: abortController.signal });
929
- } catch (error) {
930
- const sendDuration2 = Date.now() - sendStartTime;
931
- if (abortController.signal.aborted) {
932
- this.log(`[DEBUG] client.send() aborted after ${sendDuration2}ms`);
933
- this._closeSignal?.();
934
- this.client.destroy();
935
- throw new Error("client.send() timeout");
936
- }
937
- this.log(`[DEBUG] client.send() error after ${sendDuration2}ms:`, error);
938
- throw error;
939
- } finally {
940
- clearTimeout(timeoutId);
941
- }
942
- const sendDuration = Date.now() - sendStartTime;
943
- this.log(`[DEBUG] client.send() completed in ${sendDuration}ms`);
944
- this.log("Received response from AWS Bedrock");
945
- this.stream = response.body;
946
- this.log(
947
- `[DEBUG] Response stream is async iterable: ${this.stream && typeof this.stream[Symbol.asyncIterator] === "function"}`
948
- );
949
- this.state = "connected";
950
- this.log(`[STATE] State set to 'connected'`);
951
- }
952
- /**
953
- * Process the bidirectional stream from AWS Bedrock
954
- */
955
- async processStream() {
956
- if (!this.stream) {
957
- this.log("[Stream] No stream available, cannot process");
958
- return;
959
- }
960
- if (this.processingStream) {
961
- this.log("[Stream] Already processing stream, skipping");
962
- return;
963
- }
964
- this.processingStream = true;
965
- this.log("[Stream] Starting stream processing");
966
- let eventCount = 0;
967
- let lastEventTime = Date.now();
968
- try {
969
- for await (const chunk of this.stream) {
970
- if (chunk.chunk) {
971
- const textResponse = Buffer.from(chunk.chunk.bytes || []).toString("utf-8");
972
- eventCount++;
973
- const now = Date.now();
974
- const timeSinceLastEvent = now - lastEventTime;
975
- lastEventTime = now;
976
- this.log(
977
- `[Stream] Received chunk #${eventCount}, length: ${textResponse.length}, time since last: ${timeSinceLastEvent}ms`
978
- );
979
- try {
980
- const jsonResponse = JSON.parse(textResponse);
981
- this.log(`[Stream] ========================================`);
982
- this.log(`[Stream] Parsed JSON response, keys: ${Object.keys(jsonResponse).join(", ")}`);
983
- if (jsonResponse.event) {
984
- const eventKeys = Object.keys(jsonResponse.event);
985
- this.log(`[Stream] Event keys: ${eventKeys.join(", ")}`);
986
- if (jsonResponse.event.contentStart) {
987
- this.log(`[Stream] \u2192 Handling contentStart`);
988
- this.handleServerEvent({ contentStart: jsonResponse.event.contentStart });
989
- } else if (jsonResponse.event.textOutput) {
990
- this.log(
991
- `[Stream] \u2192 Handling textOutput, content length: ${jsonResponse.event.textOutput?.content?.length ?? 0}`
992
- );
993
- this.handleServerEvent({ textOutput: jsonResponse.event.textOutput });
994
- } else if (jsonResponse.event.audioOutput) {
995
- this.handleServerEvent({ audioOutput: jsonResponse.event.audioOutput });
996
- } else if (jsonResponse.event.toolUse) {
997
- this.handleServerEvent({ toolUse: jsonResponse.event.toolUse });
998
- } else if (jsonResponse.event.contentEnd && jsonResponse.event.contentEnd.type === "TOOL") {
999
- this.handleServerEvent({ contentEnd: jsonResponse.event.contentEnd });
1000
- } else if (jsonResponse.event.contentEnd) {
1001
- this.log(
1002
- `[Stream] Found contentEnd, type: ${jsonResponse.event.contentEnd.type}, stopReason: ${jsonResponse.event.contentEnd.stopReason}`
1003
- );
1004
- this.handleServerEvent({ contentEnd: jsonResponse.event.contentEnd });
1005
- } else if (jsonResponse.event.completionStart) {
1006
- this.log(
1007
- "[Stream] Found completionStart inside event object:",
1008
- JSON.stringify(jsonResponse.event.completionStart, null, 2)
1009
- );
1010
- this.emit("completionStart", jsonResponse.event.completionStart);
1011
- } else if (jsonResponse.event.completionEnd) {
1012
- this.log(
1013
- "[Stream] Found completionEnd inside event object:",
1014
- JSON.stringify(jsonResponse.event.completionEnd, null, 2)
1015
- );
1016
- this.handleServerEvent({ completionEnd: jsonResponse.event.completionEnd });
1017
- } else {
1018
- const eventKeys2 = Object.keys(jsonResponse.event || {});
1019
- this.log(`[Stream] Event keys for other events: ${eventKeys2.join(", ")}`);
1020
- if (eventKeys2.length > 0) {
1021
- if (eventKeys2.includes("completionEnd")) {
1022
- this.log("[Stream] Found completionEnd in other events, handling explicitly");
1023
- this.handleServerEvent({ completionEnd: jsonResponse.event.completionEnd });
1024
- } else {
1025
- const eventKey = eventKeys2[0];
1026
- this.log(`[Stream] Dispatching other event: ${eventKey}`);
1027
- const eventValue = jsonResponse.event[eventKey];
1028
- if (eventValue !== void 0) {
1029
- if (eventKey === "completionEnd") {
1030
- this.handleServerEvent({ completionEnd: eventValue });
1031
- } else {
1032
- this.handleServerEvent({ [eventKey]: eventValue });
1033
- }
1034
- }
1035
- }
1036
- } else if (Object.keys(jsonResponse).length > 0) {
1037
- this.log(`[Stream] Unknown event structure, keys:`, Object.keys(jsonResponse).join(", "));
1038
- }
1039
- }
1040
- } else {
1041
- if (this.debug) {
1042
- this.log(
1043
- '[Stream] Received event without "event" wrapper, keys:',
1044
- Object.keys(jsonResponse).join(", ")
1045
- );
1046
- }
1047
- if (jsonResponse.usageEvent) {
1048
- this.emit("usage", {
1049
- inputTokens: jsonResponse.usageEvent.totalInputTokens || 0,
1050
- outputTokens: jsonResponse.usageEvent.totalOutputTokens || 0,
1051
- totalTokens: jsonResponse.usageEvent.totalTokens || 0
1052
- });
1053
- }
1054
- if (jsonResponse.completionEnd) {
1055
- this.log(
1056
- "[Stream] Found completionEnd at top level:",
1057
- JSON.stringify(jsonResponse.completionEnd, null, 2)
1058
- );
1059
- this.handleServerEvent({ completionEnd: jsonResponse.completionEnd });
1060
- }
1061
- if (!jsonResponse.event && !jsonResponse.completionEnd && !jsonResponse.usageEvent) {
1062
- this.log(
1063
- "[Stream] Received response without event wrapper, keys:",
1064
- Object.keys(jsonResponse).join(", ")
1065
- );
1066
- }
1067
- if (jsonResponse.completionStart || jsonResponse.event?.completionStart) {
1068
- const completionStart = jsonResponse.completionStart || jsonResponse.event.completionStart;
1069
- this.log("[Stream] Found completionStart:", JSON.stringify(completionStart, null, 2));
1070
- this.emit("completionStart", completionStart);
1071
- }
1072
- }
1073
- } catch (parseError) {
1074
- this.log("[Stream] Failed to parse JSON response:", textResponse.substring(0, 200));
1075
- this.emit("error", {
1076
- message: "Failed to parse stream response",
1077
- code: "PARSE_ERROR",
1078
- details: parseError
1079
- });
1080
- }
1081
- } else if (chunk.internalServerException) {
1082
- this.emit("error", {
1083
- message: "Internal server error",
1084
- code: "INTERNAL_SERVER_ERROR",
1085
- details: chunk.internalServerException
1086
- });
1087
- } else if (chunk.modelStreamErrorException) {
1088
- this.emit("error", {
1089
- message: "Model stream error",
1090
- code: "MODEL_STREAM_ERROR",
1091
- details: chunk.modelStreamErrorException
1092
- });
1093
- } else if (chunk.modelTimeoutException) {
1094
- this.emit("error", {
1095
- message: "Model timeout",
1096
- code: "MODEL_TIMEOUT",
1097
- details: chunk.modelTimeoutException
1098
- });
1099
- } else if (chunk.serviceUnavailableException) {
1100
- this.emit("error", {
1101
- message: "Service unavailable",
1102
- code: "SERVICE_UNAVAILABLE",
1103
- details: chunk.serviceUnavailableException
1104
- });
1105
- } else if (chunk.throttlingException) {
1106
- this.emit("error", {
1107
- message: "Request throttled",
1108
- code: "THROTTLING",
1109
- details: chunk.throttlingException
1110
- });
1111
- } else if (chunk.validationException) {
1112
- this.emit("error", {
1113
- message: "Validation error",
1114
- code: "VALIDATION_ERROR",
1115
- details: chunk.validationException
1116
- });
1117
- }
1118
- }
1119
- } catch (streamError) {
1120
- this.log("[Stream] Error in processStream:", streamError);
1121
- this.emit("error", {
1122
- message: "Stream processing error",
1123
- code: "STREAM_ERROR",
1124
- details: streamError instanceof Error ? streamError.message : String(streamError)
1125
- });
1126
- } finally {
1127
- this.processingStream = false;
1128
- this.log(
1129
- `[Stream] processStream finished, processingStream set to false. Total events received: ${eventCount || 0}`
1130
- );
1131
- this.log(`[Stream] Stream state: state=${this.state}, stream exists=${!!this.stream}`);
1132
- if (!this.turnCompleted && this.audioContentStarted) {
1133
- this.log("[Stream] Stream ended but turn not completed - signaling turn completion as fallback");
1134
- this.log(
1135
- `[Stream] State: turnCompleted=${this.turnCompleted}, audioContentStarted=${this.audioContentStarted}, hasSentContentEnd=${this.hasSentContentEnd}`
1136
- );
1137
- this.turnCompleted = true;
1138
- this.emit("turnComplete", { timestamp: Date.now() });
1139
- if (this.currentResponseId) {
1140
- const stream = this.speakerStreams.get(this.currentResponseId);
1141
- if (stream) {
1142
- stream.end();
1143
- }
1144
- this.speakerStreams.delete(this.currentResponseId);
1145
- this.currentResponseId = void 0;
1146
- }
1147
- this.hasSentContentEnd = false;
1148
- this.log("[Stream] Turn completion signaled, ready for next turn");
1149
- } else if (this.turnCompleted) {
1150
- this.log("[Stream] Stream ended and turn was already completed");
1151
- } else {
1152
- this.log(
1153
- `[Stream] Stream ended but turn not completed - audioContentStarted=${this.audioContentStarted}, turnCompleted=${this.turnCompleted}`
1154
- );
1155
- }
1156
- if (this.stream && this.state === "connected" && !this.processingStream && !this.streamRestartAttempted) {
1157
- this.log("[Stream] Stream still open but processing stopped - will restart stream processing");
1158
- this.streamRestartAttempted = true;
1159
- setImmediate(() => {
1160
- if (this.stream && this.state === "connected" && !this.processingStream) {
1161
- this.log("[Stream] Restarting stream processing for subsequent turns");
1162
- this.processStream().catch((error) => {
1163
- this.log("[Stream] Error restarting stream processing:", error);
1164
- this.streamRestartAttempted = false;
1165
- });
1166
- } else {
1167
- this.streamRestartAttempted = false;
1168
- }
1169
- });
1170
- } else {
1171
- if (this.streamRestartAttempted) {
1172
- this.log("[Stream] Stream restart already attempted, skipping");
1173
- }
1174
- }
1175
- }
1176
- }
1177
- /**
1178
- * Handle server events from AWS Bedrock
1179
- */
1180
- handleServerEvent(event) {
1181
- if (this.debug) {
1182
- this.log("Received event, keys:", Object.keys(event).join(", "));
1183
- }
1184
- if (event.contentStart) {
1185
- this.handleContentStart(event.contentStart);
1186
- }
1187
- if (event.textOutput) {
1188
- this.handleTextOutput(event.textOutput);
1189
- }
1190
- if (event.audioOutput?.content) {
1191
- this.handleAudioOutput(event.audioOutput);
1192
- }
1193
- if (event.toolUse) {
1194
- this.handleToolUse(event.toolUse);
1195
- }
1196
- if (event.contentEnd) {
1197
- this.handleContentEnd(event.contentEnd);
1198
- }
1199
- if (event.completionEnd) {
1200
- this.handleCompletionEnd(event.completionEnd);
1201
- }
1202
- if (event.error) {
1203
- this.emit("error", {
1204
- message: event.error.message || "Unknown error",
1205
- code: event.error.code || "UNKNOWN_ERROR",
1206
- details: event.error
1207
- });
1208
- }
1209
- }
1210
- /**
1211
- * Handle a contentStart event. Tracks generationStage for text content
1212
- * blocks so the corresponding 'writing' events can be tagged
1213
- * SPECULATIVE/FINAL for the client.
1214
- */
1215
- handleContentStart(contentStart) {
1216
- const role = contentStart.role?.toLowerCase();
1217
- const contentType = contentStart.type;
1218
- this.log(`[Event] contentStart: type=${contentType || "unknown"}, role=${role}`);
1219
- this.emit("contentStart", contentStart);
1220
- if (contentType === "TEXT" && contentStart.additionalModelFields) {
1221
- try {
1222
- const additionalFields = JSON.parse(contentStart.additionalModelFields);
1223
- this.currentTextGenerationStage = additionalFields.generationStage;
1224
- this.log(`[Event] Text content generationStage: ${this.currentTextGenerationStage}`);
1225
- } catch {
1226
- this.currentTextGenerationStage = void 0;
1227
- }
1228
- } else if (contentType === "TEXT") {
1229
- this.currentTextGenerationStage = void 0;
1230
- }
1231
- }
1232
- /**
1233
- * Handle a textOutput event. Detects interruption (barge-in) markers in
1234
- * the payload, otherwise emits a 'writing' event with the text and
1235
- * current generationStage.
1236
- */
1237
- handleTextOutput(textOutput) {
1238
- const text = textOutput.content || "";
1239
- const role = textOutput.role?.toLowerCase() || "assistant";
1240
- this.log(`[Event] textOutput received: role=${role}, text length=${text.length}`);
1241
- let isInterrupted = false;
1242
- try {
1243
- const parsed = JSON.parse(text);
1244
- if (parsed && parsed.interrupted === true) {
1245
- isInterrupted = true;
1246
- }
1247
- } catch {
1248
- if (/interrupted/i.test(text)) {
1249
- isInterrupted = true;
1250
- }
1251
- }
1252
- if (isInterrupted) {
1253
- this.log(`[Event] Interrupt detected, emitting interrupt event`);
1254
- this.emit("interrupt", { type: "user", timestamp: Date.now() });
1255
- return;
1256
- }
1257
- const generationStage = this.currentTextGenerationStage;
1258
- this.log(`[Event] Emitting 'writing': role=${role}, generationStage=${generationStage}, length=${text.length}`);
1259
- this.emit("writing", { text, role, generationStage });
1260
- }
1261
- /**
1262
- * Handle an audioOutput event. Decodes the base64 LPCM payload, emits
1263
- * 'speaking' with both the base64 string and an Int16Array view, and
1264
- * forwards bytes to any active speaker stream.
1265
- */
1266
- handleAudioOutput(audioOutput) {
1267
- try {
1268
- const content = audioOutput.content;
1269
- const audioBytes = Buffer.from(content, "base64");
1270
- this.log(`[Event] Audio output: ${audioBytes.length} bytes`);
1271
- this.isReceivingAssistantAudio = true;
1272
- const audioData = new Int16Array(audioBytes.buffer, audioBytes.byteOffset, audioBytes.byteLength / 2);
1273
- this.emit("speaking", {
1274
- audio: content,
1275
- audioData,
1276
- response_id: this.currentResponseId
1277
- });
1278
- if (this.currentResponseId) {
1279
- const stream = this.speakerStreams.get(this.currentResponseId);
1280
- if (stream) {
1281
- stream.write(audioBytes);
1282
- }
1283
- }
1284
- } catch (error) {
1285
- this.log("[Event] Error decoding audio:", error);
1286
- this.emit("error", {
1287
- message: "Failed to decode audio",
1288
- code: "AUDIO_DECODE_ERROR",
1289
- details: error
1290
- });
1291
- }
1292
- }
1293
- /**
1294
- * Handle a toolUse event. Emits 'toolCall' and dispatches to the
1295
- * configured tool's execute() function via handleToolCall().
1296
- */
1297
- handleToolUse(toolUse) {
1298
- const toolUseId = toolUse.toolUseId || "";
1299
- const toolName = toolUse.toolName || "";
1300
- const toolInput = toolUse.input || {};
1301
- this.emit("toolCall", {
1302
- name: toolName,
1303
- args: toolInput,
1304
- id: toolUseId
1305
- });
1306
- if (this.tools && toolName in this.tools) {
1307
- void this.handleToolCall(toolName, toolInput, toolUseId);
1308
- }
1309
- }
1310
- /**
1311
- * Handle a contentEnd event. Forwards it to clients, then routes by
1312
- * stopReason / type:
1313
- * - INTERRUPTED: emit 'interrupt' and tear down the active speaker stream
1314
- * - TOOL: end the active speaker stream
1315
- * - AUDIO with END_TURN: signal turnComplete (assistant audio finished)
1316
- * - AUDIO with PARTIAL_TURN while receiving assistant audio: schedule
1317
- * fallback turnComplete in case completionEnd never arrives
1318
- * - AUDIO otherwise: user input ended, reset turn flags
1319
- */
1320
- handleContentEnd(contentEnd) {
1321
- this.log(`[Event] contentEnd received: type=${contentEnd.type}, stopReason=${contentEnd.stopReason}`);
1322
- this.emit("contentEnd", contentEnd);
1323
- if (contentEnd.stopReason === "INTERRUPTED") {
1324
- this.log("[Event] Content interrupted by user (barge-in)");
1325
- this.emit("interrupt", { type: "user", timestamp: Date.now() });
1326
- if (this.currentResponseId) {
1327
- const stream = this.speakerStreams.get(this.currentResponseId);
1328
- if (stream) {
1329
- stream.destroy();
1330
- }
1331
- this.speakerStreams.delete(this.currentResponseId);
1332
- }
1333
- this.currentResponseId = void 0;
1334
- this.log("[Event] After interruption, keeping audioContentStarted=true for continued streaming");
1335
- } else if (contentEnd.type === "TOOL" && this.currentResponseId) {
1336
- const stream = this.speakerStreams.get(this.currentResponseId);
1337
- if (stream) {
1338
- stream.end();
1339
- }
1340
- } else if (contentEnd.type === "AUDIO") {
1341
- if (contentEnd.stopReason === "END_TURN") {
1342
- this.log(`[Event] contentEnd (AUDIO) with stopReason END_TURN - signaling turn complete`);
1343
- if (this.currentResponseId) {
1344
- const stream = this.speakerStreams.get(this.currentResponseId);
1345
- if (stream) {
1346
- stream.end();
1347
- }
1348
- this.speakerStreams.delete(this.currentResponseId);
1349
- this.currentResponseId = void 0;
1350
- }
1351
- if (!this.turnCompleted) {
1352
- this.turnCompleted = true;
1353
- this.emit("turnComplete", { timestamp: Date.now() });
1354
- this.hasSentContentEnd = false;
1355
- this.log(
1356
- `[Event] Turn complete (from contentEnd AUDIO with END_TURN), ready for next turn. audioContentStarted: ${this.audioContentStarted}, audioContentName: ${this.audioContentName}`
1357
- );
1358
- } else {
1359
- this.log(
1360
- `[Event] contentEnd (AUDIO) with END_TURN received but turn already completed - skipping duplicate turnComplete emission`
1361
- );
1362
- }
1363
- if (!this.turnCompleteTimeout) {
1364
- this.turnCompleteTimeout = setTimeout(() => {
1365
- this.log(`[Event] Timeout: completionEnd not received, but turn already completed from contentEnd`);
1366
- this.turnCompleteTimeout = void 0;
1367
- }, 1e3);
1368
- }
1369
- } else {
1370
- if (this.isReceivingAssistantAudio && contentEnd.stopReason === "PARTIAL_TURN") {
1371
- this.isReceivingAssistantAudio = false;
1372
- if (!this.turnCompleteTimeout && !this.turnCompleted) {
1373
- this.log(
1374
- `[Event] contentEnd (AUDIO) with PARTIAL_TURN for assistant output - waiting for completionEnd, setting fallback timeout`
1375
- );
1376
- this.turnCompleteTimeout = setTimeout(() => {
1377
- if (!this.turnCompleted) {
1378
- this.log(
1379
- `[Event] Fallback: completionEnd not received after contentEnd (AUDIO) with PARTIAL_TURN, signaling turn complete`
1380
- );
1381
- this.turnCompleted = true;
1382
- this.emit("turnComplete", { timestamp: Date.now() });
1383
- if (this.currentResponseId) {
1384
- const stream = this.speakerStreams.get(this.currentResponseId);
1385
- if (stream) {
1386
- stream.end();
1387
- }
1388
- this.speakerStreams.delete(this.currentResponseId);
1389
- this.currentResponseId = void 0;
1390
- }
1391
- this.hasSentContentEnd = false;
1392
- this.turnCompleteTimeout = void 0;
1393
- }
1394
- }, 2e3);
1395
- }
1396
- } else {
1397
- this.hasSentContentEnd = false;
1398
- this.turnCompleted = false;
1399
- this.log(
1400
- `[Event] contentEnd (AUDIO) - user input ended, stopReason: ${contentEnd.stopReason}. Keeping audioContentStarted=true for next turn. Reset hasSentContentEnd=false, turnCompleted=false.`
1401
- );
1402
- }
1403
- }
1404
- } else if (contentEnd.type === "TEXT") {
1405
- this.currentTextGenerationStage = void 0;
1406
- this.log(
1407
- `[Event] contentEnd (TEXT) received, stopReason: ${contentEnd.stopReason}. Turn completion handled by completionEnd/contentEnd(AUDIO).`
1408
- );
1409
- if (contentEnd.stopReason === "END_TURN") {
1410
- this.hasSentContentEnd = false;
1411
- }
1412
- }
1413
- }
1414
- /**
1415
- * Handle a completionEnd event. AWS uses this as the definitive signal
1416
- * that a turn (and all audio output) has finished. Tears down the active
1417
- * speaker stream, clears any fallback timer, emits 'turnComplete' once,
1418
- * and forwards token usage if reported.
1419
- */
1420
- handleCompletionEnd(completionEnd) {
1421
- this.log(`[Event] completionEnd received, stopReason: ${completionEnd.stopReason}`);
1422
- if (this.turnCompleteTimeout) {
1423
- clearTimeout(this.turnCompleteTimeout);
1424
- this.turnCompleteTimeout = void 0;
1425
- }
1426
- if (this.currentResponseId) {
1427
- const stream = this.speakerStreams.get(this.currentResponseId);
1428
- if (stream) {
1429
- stream.end();
1430
- }
1431
- this.speakerStreams.delete(this.currentResponseId);
1432
- this.currentResponseId = void 0;
1433
- }
1434
- this.isReceivingAssistantAudio = false;
1435
- if (!this.turnCompleted) {
1436
- this.log(
1437
- `[Event] completionEnd - signaling turn complete (stopReason: ${completionEnd.stopReason || "undefined"})`
1438
- );
1439
- this.turnCompleted = true;
1440
- this.emit("turnComplete", { timestamp: Date.now() });
1441
- this.hasSentContentEnd = false;
1442
- } else {
1443
- this.log(`[Event] completionEnd received but turn already completed - skipping duplicate turnComplete emission`);
1444
- }
1445
- if (completionEnd.usage) {
1446
- this.emit("usage", {
1447
- inputTokens: completionEnd.usage.inputTokens || 0,
1448
- outputTokens: completionEnd.usage.outputTokens || 0,
1449
- totalTokens: (completionEnd.usage.inputTokens || 0) + (completionEnd.usage.outputTokens || 0)
1450
- });
1451
- }
1452
- }
1453
- /**
1454
- * Handle tool execution
1455
- */
1456
- async handleToolCall(toolName, args, toolUseId) {
1457
- const tool = this.tools?.[toolName];
1458
- if (!tool || !tool.execute) {
1459
- this.emit("error", {
1460
- message: `Tool ${toolName} not found or has no execute function`,
1461
- code: "TOOL_NOT_FOUND"
1462
- });
1463
- return;
1464
- }
1465
- try {
1466
- const result = await tool.execute(
1467
- { context: args, requestContext: this.requestContext },
1468
- {
1469
- toolCallId: toolUseId,
1470
- messages: []
1471
- }
1472
- );
1473
- await this.sendClientEvent({
1474
- toolResult: {
1475
- toolUseId,
1476
- content: [
1477
- {
1478
- json: typeof result === "object" ? result : { result }
1479
- }
1480
- ]
1481
- }
1482
- });
1483
- } catch (error) {
1484
- this.emit("error", {
1485
- message: `Error executing tool ${toolName}: ${error instanceof Error ? error.message : "Unknown error"}`,
1486
- code: "TOOL_EXECUTION_ERROR",
1487
- details: error
1488
- });
1489
- await this.sendClientEvent({
1490
- toolResult: {
1491
- toolUseId,
1492
- content: [
1493
- {
1494
- text: `Error: ${error instanceof Error ? error.message : "Unknown error"}`
1495
- }
1496
- ]
1497
- }
1498
- });
1499
- }
1500
- }
1501
- /**
1502
- * Send a client event to AWS Bedrock
1503
- * Events are sent through the input stream that was passed to the bidirectional stream command
1504
- */
1505
- async sendClientEvent(event) {
1506
- if (this.state !== "connected") {
1507
- throw new NovaSonicError("not_connected" /* NOT_CONNECTED */, "Not connected to AWS Bedrock. Call connect() first.");
1508
- }
1509
- try {
1510
- const eventQueue = this._eventQueue;
1511
- const signalQueue = this._signalQueue;
1512
- if (!eventQueue || !signalQueue) {
1513
- throw new NovaSonicError(
1514
- "not_connected" /* NOT_CONNECTED */,
1515
- "Event queue not initialized. Connection may not be fully established."
1516
- );
1517
- }
1518
- this.log(`[sendClientEvent] Adding event to queue (queue size: ${eventQueue.length})`);
1519
- eventQueue.push({ event });
1520
- this.log(`[sendClientEvent] Event added, queue size now: ${eventQueue.length}, signaling...`);
1521
- signalQueue();
1522
- this.log(`[sendClientEvent] Signal sent`);
1523
- if (this.debug) {
1524
- this.log("Sent client event, keys:", Object.keys(event).join(", "));
1525
- }
1526
- } catch (error) {
1527
- throw new NovaSonicError(
1528
- "websocket_error" /* WEBSOCKET_ERROR */,
1529
- `Failed to send client event: ${error instanceof Error ? error.message : "Unknown error"}`,
1530
- error
1531
- );
1532
- }
1533
- }
1534
- /**
1535
- * Disconnects from the AWS Bedrock session and cleans up resources.
1536
- *
1537
- * Pushes a `sessionEnd` event to the queue before signalling close,
1538
- * then schedules client destruction on the next tick so the async
1539
- * iterator has a chance to yield the event to the SDK.
1540
- */
1541
- close() {
1542
- if (this.state === "disconnected") {
1543
- return;
1544
- }
1545
- this.state = "disconnected";
1546
- this.processingStream = false;
1547
- if (this.turnCompleteTimeout) {
1548
- clearTimeout(this.turnCompleteTimeout);
1549
- this.turnCompleteTimeout = void 0;
1550
- }
1551
- const eventQueue = this._eventQueue;
1552
- const signalQueue = this._signalQueue;
1553
- if (eventQueue && signalQueue) {
1554
- eventQueue.push({ event: { sessionEnd: {} } });
1555
- signalQueue();
1556
- }
1557
- const closeSignal = this._closeSignal;
1558
- if (closeSignal) {
1559
- closeSignal();
1560
- }
1561
- if (this.inputStream) {
1562
- this.inputStream.end();
1563
- this.inputStream = void 0;
1564
- }
1565
- for (const stream of this.speakerStreams.values()) {
1566
- stream.end();
1567
- }
1568
- this.speakerStreams.clear();
1569
- const client = this.client;
1570
- this.client = void 0;
1571
- this.stream = void 0;
1572
- if (client) {
1573
- setImmediate(() => {
1574
- if (typeof client.destroy === "function") {
1575
- client.destroy();
1576
- }
1577
- });
1578
- }
1579
- this.log("Disconnected from AWS Bedrock Nova 2 Sonic");
1580
- }
1581
- /**
1582
- * Equips the voice instance with a set of instructions.
1583
- */
1584
- addInstructions(instructions) {
1585
- this.instructions = instructions;
1586
- }
1587
- /**
1588
- * Equips the voice instance with a set of tools.
1589
- */
1590
- addTools(tools) {
1591
- this.tools = tools || {};
1592
- }
1593
- /**
1594
- * Convert text to speech
1595
- */
1596
- async speak(input, _options) {
1597
- if (this.state !== "connected") {
1598
- throw new NovaSonicError("not_connected" /* NOT_CONNECTED */, "Not connected. Call connect() first.");
1599
- }
1600
- let text = "";
1601
- if (typeof input !== "string") {
1602
- const chunks = [];
1603
- for await (const chunk of input) {
1604
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
1605
- }
1606
- text = Buffer.concat(chunks).toString("utf-8");
1607
- } else {
1608
- text = input;
1609
- }
1610
- if (text.trim().length === 0) {
1611
- throw new NovaSonicError("validation_error" /* VALIDATION_ERROR */, "Input text is empty");
1612
- }
1613
- this.currentResponseId = `response-${Date.now()}`;
1614
- const speakerStream = new stream.PassThrough();
1615
- speakerStream.id = this.currentResponseId;
1616
- this.speakerStreams.set(this.currentResponseId, speakerStream);
1617
- this.emit("speaker", speakerStream);
1618
- const promptName = this._promptName;
1619
- if (!promptName) {
1620
- throw new NovaSonicError(
1621
- "not_connected" /* NOT_CONNECTED */,
1622
- "Prompt name not initialized. Connection may not be fully established."
1623
- );
1624
- }
1625
- if (!this.promptStarted) {
1626
- throw new NovaSonicError(
1627
- "invalid_state" /* INVALID_STATE */,
1628
- "Prompt not started. This should not happen - prompt should be started during connection."
1629
- );
1630
- }
1631
- const contentName = crypto.randomUUID();
1632
- await this.sendClientEvent({
1633
- contentStart: {
1634
- promptName,
1635
- contentName,
1636
- type: "TEXT",
1637
- interactive: true,
1638
- role: "USER",
1639
- textInputConfiguration: {
1640
- mediaType: "text/plain"
1641
- }
1642
- }
1643
- });
1644
- await this.sendClientEvent({
1645
- textInput: {
1646
- promptName,
1647
- contentName,
1648
- content: text
1649
- }
1650
- });
1651
- await this.sendClientEvent({
1652
- contentEnd: {
1653
- promptName,
1654
- contentName
1655
- }
1656
- });
1657
- }
1658
- /**
1659
- * Convert speech to text (transcription)
1660
- * For Nova Sonic, this is the same as send() - both stream audio input
1661
- */
1662
- async listen(audioStream, _options) {
1663
- if (audioStream && typeof audioStream === "object" && "read" in audioStream) {
1664
- await this.send(audioStream);
1665
- } else {
1666
- throw new NovaSonicError("invalid_audio_format" /* INVALID_AUDIO_FORMAT */, "Unsupported audio stream format for listen()");
1667
- }
1668
- }
1669
- /**
1670
- * Streams audio data in real-time to the AWS Bedrock service.
1671
- * Following AWS Nova 2 Sonic event sequence:
1672
- * 1. contentStart (AUDIO, USER) - if not already sent
1673
- * 2. audioInput events (one per chunk)
1674
- * 3. contentEnd - when audio stream ends (handled separately via endAudioInput)
1675
- */
1676
- async send(audioData) {
1677
- this.log(`[send] Current state: ${this.state}`);
1678
- if (this.state !== "connected") {
1679
- this.log(`[send] ERROR: State is '${this.state}', expected 'connected'`);
1680
- throw new NovaSonicError(
1681
- "not_connected" /* NOT_CONNECTED */,
1682
- `Not connected. Current state: ${this.state}. Call connect() first.`
1683
- );
1684
- }
1685
- this.log(`[send] State check passed, proceeding with send`);
1686
- if (!(audioData instanceof Int16Array) && !(audioData && typeof audioData === "object" && "read" in audioData)) {
1687
- throw new NovaSonicError("invalid_audio_format" /* INVALID_AUDIO_FORMAT */, "Unsupported audio data format");
1688
- }
1689
- if (this.turnCompleted || this.hasSentContentEnd) {
1690
- this.log(
1691
- `[send] Starting new turn - resetting flags. turnCompleted=${this.turnCompleted}, hasSentContentEnd=${this.hasSentContentEnd}.`
1692
- );
1693
- const needNewContent = this.hasSentContentEnd;
1694
- this.turnCompleted = false;
1695
- this.hasSentContentEnd = false;
1696
- this.streamRestartAttempted = false;
1697
- if (needNewContent) {
1698
- this.audioContentStarted = false;
1699
- this.log(`[send] contentEnd was previously sent - will create new audio content container`);
1700
- }
1701
- this.log(
1702
- `[send] State reset: turnCompleted=false, hasSentContentEnd=false, audioContentStarted=${this.audioContentStarted}`
1703
- );
1704
- }
1705
- if (!this.promptStarted) {
1706
- this.promptStarted = true;
1707
- }
1708
- const promptName = this._promptName;
1709
- if (!promptName) {
1710
- throw new NovaSonicError(
1711
- "not_connected" /* NOT_CONNECTED */,
1712
- "Prompt name not initialized. Connection may not be fully established."
1713
- );
1714
- }
1715
- if (!this.audioContentStarted) {
1716
- const audioContentId = crypto.randomUUID();
1717
- this.audioContentName = audioContentId;
1718
- this.log(`[send] First audio send - sending AUDIO contentStart with contentName: ${audioContentId}`);
1719
- await this.sendClientEvent({
1720
- contentStart: {
1721
- promptName,
1722
- contentName: audioContentId,
1723
- type: "AUDIO",
1724
- interactive: true,
1725
- role: "USER",
1726
- audioInputConfiguration: {
1727
- mediaType: "audio/lpcm",
1728
- sampleRateHertz: 16e3,
1729
- sampleSizeBits: 16,
1730
- channelCount: 1,
1731
- encoding: "base64",
1732
- audioType: "SPEECH"
1733
- }
1734
- }
1735
- });
1736
- this.audioContentStarted = true;
1737
- this.log(`[send] AUDIO contentStart sent, ready to stream audio`);
1738
- } else {
1739
- this.log(`[send] AUDIO contentStart already sent, sending audioInput chunks directly`);
1740
- }
1741
- if (!this.audioContentName) {
1742
- throw new NovaSonicError("invalid_state" /* INVALID_STATE */, "Audio content name not initialized. This should not happen.");
1743
- }
1744
- const contentName = this.audioContentName;
1745
- if (audioData instanceof Int16Array) {
1746
- const buffer = Buffer.from(audioData.buffer, audioData.byteOffset, audioData.byteLength);
1747
- const base64Audio = buffer.toString("base64");
1748
- this.log(
1749
- `[send] Sending audioInput chunk, size: ${buffer.length} bytes, contentName: ${contentName}, turnCompleted: ${this.turnCompleted}, hasSentContentEnd: ${this.hasSentContentEnd}, audioContentStarted: ${this.audioContentStarted}, state: ${this.state}`
1750
- );
1751
- if (this.state !== "connected") {
1752
- this.log(`[send] ERROR: State changed to '${this.state}' during send!`);
1753
- throw new NovaSonicError("not_connected" /* NOT_CONNECTED */, `Connection lost during send. State: ${this.state}`);
1754
- }
1755
- await this.sendClientEvent({
1756
- audioInput: {
1757
- promptName,
1758
- contentName,
1759
- content: base64Audio
1760
- }
1761
- });
1762
- this.log(`[send] audioInput chunk sent successfully`);
1763
- } else if (audioData && typeof audioData === "object" && "read" in audioData) {
1764
- const stream = audioData;
1765
- for await (const chunk of stream) {
1766
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1767
- const base64Audio = buffer.toString("base64");
1768
- this.log(
1769
- `[send] Sending audioInput chunk from stream, size: ${buffer.length} bytes, contentName: ${contentName}, turnCompleted: ${this.turnCompleted}, hasSentContentEnd: ${this.hasSentContentEnd}`
1770
- );
1771
- await this.sendClientEvent({
1772
- audioInput: {
1773
- promptName,
1774
- contentName,
1775
- content: base64Audio
1776
- }
1777
- });
1778
- }
1779
- } else {
1780
- throw new NovaSonicError("invalid_audio_format" /* INVALID_AUDIO_FORMAT */, "Unsupported audio data format");
1781
- }
1782
- }
1783
- /**
1784
- * End audio input stream (sends contentEnd for audio)
1785
- * Call this when done sending audio chunks
1786
- */
1787
- async endAudioInput() {
1788
- if (this.hasSentContentEnd) {
1789
- this.log("[endAudioInput] contentEnd already sent for this turn, skipping");
1790
- return;
1791
- }
1792
- if (this.turnCompleted) {
1793
- this.log(
1794
- "[endAudioInput] Turn already completed by AWS, skipping contentEnd. Resetting turnCompleted flag for next turn."
1795
- );
1796
- this.turnCompleted = false;
1797
- this.hasSentContentEnd = false;
1798
- return;
1799
- }
1800
- if (this.audioContentStarted && this.audioContentName && this._promptName) {
1801
- const promptName = this._promptName;
1802
- this.log("[endAudioInput] Sending contentEnd for audio input");
1803
- await this.sendClientEvent({
1804
- contentEnd: {
1805
- promptName,
1806
- contentName: this.audioContentName
1807
- }
1808
- });
1809
- this.hasSentContentEnd = true;
1810
- } else {
1811
- this.log(
1812
- "[endAudioInput] Cannot send contentEnd: audioContentStarted=" + this.audioContentStarted + ", audioContentName=" + this.audioContentName
1813
- );
1814
- }
1815
- }
1816
- /**
1817
- * Register an event listener
1818
- */
1819
- on(event, callback) {
1820
- if (!this.events[event]) {
1821
- this.events[event] = [];
1822
- }
1823
- this.events[event].push(callback);
1824
- }
1825
- /**
1826
- * Remove an event listener
1827
- */
1828
- off(event, callback) {
1829
- if (!this.events[event]) {
1830
- return;
1831
- }
1832
- const index = this.events[event].indexOf(callback);
1833
- if (index !== -1) {
1834
- this.events[event].splice(index, 1);
1835
- }
1836
- }
1837
- /**
1838
- * Emit an event with arguments
1839
- */
1840
- emit(event, data) {
1841
- if (!this.events[event]) {
1842
- this.log(`[NovaSonic] emit('${event}'): No listeners registered for this event`);
1843
- return;
1844
- }
1845
- const listenerCount = this.events[event].length;
1846
- this.log(`[NovaSonic] emit('${event}'): Calling ${listenerCount} listener(s)`);
1847
- for (const callback of this.events[event]) {
1848
- try {
1849
- callback(data);
1850
- this.log(`[NovaSonic] emit('${event}'): Successfully called one listener`);
1851
- } catch (error) {
1852
- this.log(`Error in event handler for ${event}:`, error);
1853
- }
1854
- }
1855
- this.log(`[NovaSonic] emit('${event}'): Finished calling all ${listenerCount} listener(s)`);
1856
- }
1857
- /**
1858
- * Get listener status
1859
- */
1860
- async getListener() {
1861
- return { enabled: this.state === "connected" };
1862
- }
1863
- /**
1864
- * Log helper
1865
- */
1866
- log(...args) {
1867
- if (this.debug) {
1868
- console.info("[NovaSonicVoice]", ...args);
1869
- }
1870
- }
408
+ client;
409
+ stream;
410
+ inputStream;
411
+ _eventQueue;
412
+ _signalQueue;
413
+ _closeSignal;
414
+ _promptName;
415
+ state = "disconnected";
416
+ events;
417
+ instructions;
418
+ tools;
419
+ requestContext;
420
+ debug;
421
+ region;
422
+ model;
423
+ credentials;
424
+ speakerStreams;
425
+ currentResponseId;
426
+ processingStream = false;
427
+ streamRestartAttempted = false;
428
+ sessionConfig;
429
+ promptStarted = false;
430
+ audioContentName;
431
+ audioContentStarted = false;
432
+ hasSentContentEnd = false;
433
+ turnCompleted = false;
434
+ turnCompleteTimeout;
435
+ isReceivingAssistantAudio = false;
436
+ currentTextGenerationStage;
437
+ /**
438
+ * Creates a new instance of NovaSonicVoice.
439
+ *
440
+ * @param config - Configuration options for the voice instance
441
+ * @param config.region - AWS region (defaults to us-east-1)
442
+ * @param config.model - The model ID to use (defaults to amazon.nova-2-sonic-v1:0)
443
+ * @param config.credentials - AWS credentials (optional, uses default credential chain)
444
+ * @param config.speaker - Voice name/identifier
445
+ * @param config.languageCode - Language code for the voice
446
+ * @param config.debug - Enable debug mode
447
+ *
448
+ * @example
449
+ * ```typescript
450
+ * const voice = new NovaSonicVoice({
451
+ * region: 'us-east-1',
452
+ * model: 'amazon.nova-2-sonic-v1:0',
453
+ * speaker: 'default',
454
+ * });
455
+ * ```
456
+ */
457
+ constructor(config = {}) {
458
+ let normalizedConfig;
459
+ if ("realtimeConfig" in config || "speechModel" in config || "listeningModel" in config) normalizedConfig = config;
460
+ else {
461
+ const configOptions = config;
462
+ normalizedConfig = {
463
+ realtimeConfig: {
464
+ model: configOptions.model || DEFAULT_MODEL,
465
+ apiKey: void 0,
466
+ options: configOptions
467
+ },
468
+ speaker: typeof configOptions.speaker === "string" ? configOptions.speaker : "matthew"
469
+ };
470
+ }
471
+ super(normalizedConfig);
472
+ const options = normalizedConfig.realtimeConfig?.options || config;
473
+ this.region = options.region || DEFAULT_REGION;
474
+ this.model = options.model || DEFAULT_MODEL;
475
+ this.credentials = options.credentials;
476
+ this.debug = options.debug || false;
477
+ this.sessionConfig = options.sessionConfig;
478
+ this.events = {};
479
+ this.speakerStreams = /* @__PURE__ */ new Map();
480
+ const validRegions = [
481
+ "us-east-1",
482
+ "us-west-2",
483
+ "ap-northeast-1"
484
+ ];
485
+ if (!validRegions.includes(this.region)) throw new NovaSonicError("region_invalid", `Invalid region: ${this.region}. Supported regions: ${validRegions.join(", ")}`);
486
+ }
487
+ /**
488
+ * Returns a list of available voice speakers.
489
+ *
490
+ * Nova 2 Sonic provides expressive voices across multiple languages.
491
+ * Tiffany (en-US, feminine) and Matthew (en-US, masculine) are polyglot
492
+ * voices that can speak all supported languages.
493
+ *
494
+ * @returns Promise resolving to an array of voice objects
495
+ */
496
+ async getSpeakers() {
497
+ return Promise.resolve([
498
+ {
499
+ voiceId: "tiffany",
500
+ name: "Tiffany",
501
+ language: "English",
502
+ locale: "en-US",
503
+ gender: "feminine",
504
+ polyglot: true
505
+ },
506
+ {
507
+ voiceId: "matthew",
508
+ name: "Matthew",
509
+ language: "English",
510
+ locale: "en-US",
511
+ gender: "masculine",
512
+ polyglot: true
513
+ },
514
+ {
515
+ voiceId: "amy",
516
+ name: "Amy",
517
+ language: "English",
518
+ locale: "en-GB",
519
+ gender: "feminine",
520
+ polyglot: false
521
+ },
522
+ {
523
+ voiceId: "olivia",
524
+ name: "Olivia",
525
+ language: "English",
526
+ locale: "en-AU",
527
+ gender: "feminine",
528
+ polyglot: false
529
+ },
530
+ {
531
+ voiceId: "kiara",
532
+ name: "Kiara",
533
+ language: "English",
534
+ locale: "en-IN",
535
+ gender: "feminine",
536
+ polyglot: false
537
+ },
538
+ {
539
+ voiceId: "arjun",
540
+ name: "Arjun",
541
+ language: "English",
542
+ locale: "en-IN",
543
+ gender: "masculine",
544
+ polyglot: false
545
+ },
546
+ {
547
+ voiceId: "ambre",
548
+ name: "Ambre",
549
+ language: "French",
550
+ locale: "fr-FR",
551
+ gender: "feminine",
552
+ polyglot: false
553
+ },
554
+ {
555
+ voiceId: "florian",
556
+ name: "Florian",
557
+ language: "French",
558
+ locale: "fr-FR",
559
+ gender: "masculine",
560
+ polyglot: false
561
+ },
562
+ {
563
+ voiceId: "beatrice",
564
+ name: "Beatrice",
565
+ language: "Italian",
566
+ locale: "it-IT",
567
+ gender: "feminine",
568
+ polyglot: false
569
+ },
570
+ {
571
+ voiceId: "lorenzo",
572
+ name: "Lorenzo",
573
+ language: "Italian",
574
+ locale: "it-IT",
575
+ gender: "masculine",
576
+ polyglot: false
577
+ },
578
+ {
579
+ voiceId: "tina",
580
+ name: "Tina",
581
+ language: "German",
582
+ locale: "de-DE",
583
+ gender: "feminine",
584
+ polyglot: false
585
+ },
586
+ {
587
+ voiceId: "lennart",
588
+ name: "Lennart",
589
+ language: "German",
590
+ locale: "de-DE",
591
+ gender: "masculine",
592
+ polyglot: false
593
+ },
594
+ {
595
+ voiceId: "lupe",
596
+ name: "Lupe",
597
+ language: "Spanish",
598
+ locale: "es-US",
599
+ gender: "feminine",
600
+ polyglot: false
601
+ },
602
+ {
603
+ voiceId: "carlos",
604
+ name: "Carlos",
605
+ language: "Spanish",
606
+ locale: "es-US",
607
+ gender: "masculine",
608
+ polyglot: false
609
+ },
610
+ {
611
+ voiceId: "carolina",
612
+ name: "Carolina",
613
+ language: "Portuguese",
614
+ locale: "pt-BR",
615
+ gender: "feminine",
616
+ polyglot: false
617
+ },
618
+ {
619
+ voiceId: "leo",
620
+ name: "Leo",
621
+ language: "Portuguese",
622
+ locale: "pt-BR",
623
+ gender: "masculine",
624
+ polyglot: false
625
+ },
626
+ {
627
+ voiceId: "kiara",
628
+ name: "Kiara",
629
+ language: "Hindi",
630
+ locale: "hi-IN",
631
+ gender: "feminine",
632
+ polyglot: false
633
+ },
634
+ {
635
+ voiceId: "arjun",
636
+ name: "Arjun",
637
+ language: "Hindi",
638
+ locale: "hi-IN",
639
+ gender: "masculine",
640
+ polyglot: false
641
+ }
642
+ ]);
643
+ }
644
+ /**
645
+ * Establishes a connection to the AWS Bedrock bidirectional streaming service.
646
+ * Must be called before using speak, listen, or send functions.
647
+ *
648
+ * @throws {NovaSonicError} If connection fails or credentials are missing
649
+ *
650
+ * @example
651
+ * ```typescript
652
+ * await voice.connect();
653
+ * // Now ready for voice interactions
654
+ * ```
655
+ */
656
+ async connect({ requestContext } = {}) {
657
+ if (this.state === "connected" || this.state === "connecting") {
658
+ this.log("Already connected or connecting");
659
+ return;
660
+ }
661
+ this.state = "connecting";
662
+ this.requestContext = requestContext;
663
+ this.streamRestartAttempted = false;
664
+ try {
665
+ await this.createBedrockClient();
666
+ const asyncIterable = this.createEventQueue();
667
+ this.enqueueInitialSessionEvents();
668
+ await this.sendInitialConnectCommand(asyncIterable);
669
+ this.processStream().catch((error) => {
670
+ this.log("Error in stream processing:", error);
671
+ this.emit("error", {
672
+ message: error instanceof Error ? error.message : "Stream processing error",
673
+ code: "STREAM_PROCESSING_ERROR",
674
+ details: error
675
+ });
676
+ });
677
+ this.log("Connected to AWS Bedrock Nova 2 Sonic");
678
+ } catch (error) {
679
+ this.state = "disconnected";
680
+ if (this.client) {
681
+ if (typeof this.client.destroy === "function") this.client.destroy();
682
+ this.client = void 0;
683
+ }
684
+ this.log("Connection error:", error);
685
+ throw new NovaSonicError("connection_failed", `Failed to connect to AWS Bedrock: ${error instanceof Error ? error.message : "Unknown error during connection"}`, error);
686
+ }
687
+ }
688
+ /**
689
+ * Resolve credentials and initialize the Bedrock Runtime client over HTTP/2.
690
+ */
691
+ async createBedrockClient() {
692
+ this.log("Getting AWS credentials...");
693
+ const credentials = await getAwsCredentials(this.credentials, this.debug);
694
+ if (!credentials) throw new NovaSonicError("credentials_missing", "AWS credentials are required. Please configure AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables or provide credentials in the config.");
695
+ this.log("Credentials retrieved:", {
696
+ hasAccessKeyId: !!credentials.accessKeyId,
697
+ hasSecretAccessKey: !!credentials.secretAccessKey,
698
+ hasSessionToken: !!credentials.sessionToken,
699
+ accessKeyIdPrefix: credentials.accessKeyId ? `${credentials.accessKeyId.substring(0, 6)}...` : "missing",
700
+ expiration: credentials.expiration ? credentials.expiration.toISOString() : "no expiration"
701
+ });
702
+ this.log(`Initializing Bedrock Runtime client for region: ${this.region}, model: ${this.model}`);
703
+ const nodeHttp2Handler = new _smithy_node_http_handler.NodeHttp2Handler({
704
+ requestTimeout: 3e5,
705
+ sessionTimeout: 3e5,
706
+ disableConcurrentStreams: false,
707
+ maxConcurrentStreams: 20
708
+ });
709
+ this.client = new _aws_sdk_client_bedrock_runtime.BedrockRuntimeClient({
710
+ region: this.region,
711
+ credentials,
712
+ requestHandler: nodeHttp2Handler
713
+ });
714
+ }
715
+ /**
716
+ * Build the async-iterable event queue used as the request body for the
717
+ * bidirectional stream. Returns the iterable and wires up internal queue
718
+ * helpers (_eventQueue, _signalQueue, _closeSignal) used by sendClientEvent.
719
+ */
720
+ createEventQueue() {
721
+ this.log("Creating bidirectional stream command...");
722
+ const voiceInstance = this;
723
+ const eventQueue = [];
724
+ const pendingResolvers = [];
725
+ let closeSignal = false;
726
+ const signalQueue = () => {
727
+ if (pendingResolvers.length > 0) {
728
+ voiceInstance.log(`[AsyncIterable] Signaling queue - resolving ${pendingResolvers.length} pending Promise(s)`);
729
+ const resolvers = [...pendingResolvers];
730
+ pendingResolvers.length = 0;
731
+ resolvers.forEach((resolve) => resolve());
732
+ } else voiceInstance.log("[AsyncIterable] signalQueue called but no pending Promise");
733
+ };
734
+ const asyncIterable = { [Symbol.asyncIterator]: () => {
735
+ voiceInstance.log("[AsyncIterable] Iterator requested");
736
+ return {
737
+ next: async () => {
738
+ try {
739
+ if (closeSignal || voiceInstance.state === "disconnected") {
740
+ voiceInstance.log(`[AsyncIterable] Stream closed (state: ${voiceInstance.state}), done = true`);
741
+ return {
742
+ value: void 0,
743
+ done: true
744
+ };
745
+ }
746
+ if (eventQueue.length === 0) try {
747
+ voiceInstance.log("[AsyncIterable] Queue empty, waiting for signal...");
748
+ await new Promise((resolve) => {
749
+ pendingResolvers.push(resolve);
750
+ voiceInstance.log(`[AsyncIterable] Promise created, waiting for signal (${pendingResolvers.length} pending)...`);
751
+ setImmediate(() => {
752
+ if (eventQueue.length > 0) {
753
+ voiceInstance.log("[AsyncIterable] Data arrived before wait, resolving immediately");
754
+ const index = pendingResolvers.indexOf(resolve);
755
+ if (index !== -1) {
756
+ pendingResolvers.splice(index, 1);
757
+ resolve();
758
+ }
759
+ return;
760
+ }
761
+ if (closeSignal || voiceInstance.state === "disconnected") {
762
+ voiceInstance.log("[AsyncIterable] Closed before wait, resolving");
763
+ const index = pendingResolvers.indexOf(resolve);
764
+ if (index !== -1) {
765
+ pendingResolvers.splice(index, 1);
766
+ resolve();
767
+ }
768
+ return;
769
+ }
770
+ });
771
+ });
772
+ voiceInstance.log("[AsyncIterable] Promise resolved, checking queue...");
773
+ } catch (error) {
774
+ if (error instanceof Error && error.message === "Stream closed") {
775
+ voiceInstance.log("[AsyncIterable] Stream closed during wait");
776
+ return {
777
+ value: void 0,
778
+ done: true
779
+ };
780
+ }
781
+ voiceInstance.log("[AsyncIterable] Error during wait:", error);
782
+ }
783
+ if (closeSignal) {
784
+ voiceInstance.log("[AsyncIterable] Stream closed (closeSignal)");
785
+ return {
786
+ value: void 0,
787
+ done: true
788
+ };
789
+ }
790
+ if (voiceInstance.state === "disconnected") {
791
+ voiceInstance.log("[AsyncIterable] Stream closed (disconnected state)");
792
+ return {
793
+ value: void 0,
794
+ done: true
795
+ };
796
+ }
797
+ while (eventQueue.length === 0 && !closeSignal) {
798
+ if (voiceInstance.state === "disconnected") {
799
+ voiceInstance.log("[AsyncIterable] Stream closed before wait loop");
800
+ return {
801
+ value: void 0,
802
+ done: true
803
+ };
804
+ }
805
+ voiceInstance.log("[AsyncIterable] Queue still empty, waiting again...");
806
+ await new Promise((resolve) => {
807
+ pendingResolvers.push(resolve);
808
+ setImmediate(() => {
809
+ if (eventQueue.length > 0 || closeSignal || voiceInstance.state === "disconnected") {
810
+ const index = pendingResolvers.indexOf(resolve);
811
+ if (index !== -1) {
812
+ pendingResolvers.splice(index, 1);
813
+ resolve();
814
+ }
815
+ }
816
+ });
817
+ });
818
+ if (closeSignal || voiceInstance.state === "disconnected") {
819
+ voiceInstance.log("[AsyncIterable] Stream closed during wait loop");
820
+ return {
821
+ value: void 0,
822
+ done: true
823
+ };
824
+ }
825
+ }
826
+ const nextEvent = eventQueue.shift();
827
+ const eventJson = JSON.stringify(nextEvent);
828
+ const eventBytes = Buffer.from(eventJson, "utf-8");
829
+ voiceInstance.log(`[AsyncIterable] Yielding event of size: ${eventBytes.length}`);
830
+ return {
831
+ value: { chunk: { bytes: eventBytes } },
832
+ done: false
833
+ };
834
+ } catch (error) {
835
+ voiceInstance.log("[AsyncIterable] Error in iterator:", error);
836
+ closeSignal = true;
837
+ return {
838
+ value: void 0,
839
+ done: true
840
+ };
841
+ }
842
+ },
843
+ return: async () => {
844
+ voiceInstance.log("[AsyncIterable] Iterator return() called");
845
+ closeSignal = true;
846
+ signalQueue();
847
+ return {
848
+ value: void 0,
849
+ done: true
850
+ };
851
+ },
852
+ throw: async (error) => {
853
+ voiceInstance.log("[AsyncIterable] Iterator throw() called:", error);
854
+ closeSignal = true;
855
+ error instanceof Error || new Error(String(error));
856
+ signalQueue();
857
+ throw error;
858
+ }
859
+ };
860
+ } };
861
+ this._eventQueue = eventQueue;
862
+ this._signalQueue = signalQueue;
863
+ this._closeSignal = () => {
864
+ closeSignal = true;
865
+ signalQueue();
866
+ };
867
+ return asyncIterable;
868
+ }
869
+ /**
870
+ * Pre-populate the event queue with the AWS Nova Sonic connection
871
+ * handshake events: sessionStart, promptStart, then a SYSTEM text content
872
+ * block carrying the configured instructions. AUDIO contentStart is NOT
873
+ * sent here; it is deferred to the first send() call.
874
+ */
875
+ enqueueInitialSessionEvents() {
876
+ const eventQueue = this._eventQueue;
877
+ if (!eventQueue) throw new NovaSonicError("connection_failed", "Event queue must be initialized before enqueueing session events");
878
+ this.log("Pre-populating queue with sessionStart and promptStart events...");
879
+ const promptName = (0, crypto.randomUUID)();
880
+ this._promptName = promptName;
881
+ const sessionStartEvent = {};
882
+ if (this.sessionConfig) {
883
+ if (this.sessionConfig.inferenceConfiguration) sessionStartEvent.inferenceConfiguration = {
884
+ maxTokens: this.sessionConfig.inferenceConfiguration.maxTokens || 4096,
885
+ topP: this.sessionConfig.inferenceConfiguration.topP || .9,
886
+ temperature: this.sessionConfig.inferenceConfiguration.temperature || .7,
887
+ ...this.sessionConfig.inferenceConfiguration.topK !== void 0 && { topK: this.sessionConfig.inferenceConfiguration.topK },
888
+ ...this.sessionConfig.inferenceConfiguration.stopSequences && { stopSequences: this.sessionConfig.inferenceConfiguration.stopSequences }
889
+ };
890
+ else sessionStartEvent.inferenceConfiguration = {
891
+ maxTokens: 4096,
892
+ topP: .9,
893
+ temperature: .7
894
+ };
895
+ if (this.sessionConfig.turnDetectionConfiguration) sessionStartEvent.turnDetectionConfiguration = { ...this.sessionConfig.turnDetectionConfiguration.endpointingSensitivity && { endpointingSensitivity: this.sessionConfig.turnDetectionConfiguration.endpointingSensitivity } };
896
+ } else sessionStartEvent.inferenceConfiguration = {
897
+ maxTokens: 4096,
898
+ topP: .9,
899
+ temperature: .7
900
+ };
901
+ eventQueue.push({ event: { sessionStart: sessionStartEvent } });
902
+ let voiceId = "matthew";
903
+ if (this.sessionConfig?.voice) {
904
+ if (typeof this.sessionConfig.voice === "string") voiceId = this.sessionConfig.voice;
905
+ else if (this.sessionConfig.voice.name) voiceId = this.sessionConfig.voice.name;
906
+ } else if (this.speaker && this.speaker !== "default") if (typeof this.speaker === "string") voiceId = this.speaker;
907
+ else {
908
+ const speakerObj = this.speaker;
909
+ if (speakerObj && typeof speakerObj === "object" && speakerObj.name) voiceId = speakerObj.name;
910
+ }
911
+ const promptStartEvent = {
912
+ promptName,
913
+ textOutputConfiguration: { mediaType: "text/plain" },
914
+ audioOutputConfiguration: {
915
+ mediaType: "audio/lpcm",
916
+ sampleRateHertz: 24e3,
917
+ sampleSizeBits: 16,
918
+ channelCount: 1,
919
+ voiceId,
920
+ encoding: "base64",
921
+ audioType: "SPEECH"
922
+ }
923
+ };
924
+ if (this.sessionConfig?.tools && this.sessionConfig.tools.length > 0) promptStartEvent.toolConfiguration = {
925
+ tools: this.sessionConfig.tools.map((tool) => {
926
+ let inputSchemaJson;
927
+ if (typeof tool.inputSchema === "string") inputSchemaJson = tool.inputSchema;
928
+ else inputSchemaJson = JSON.stringify(tool.inputSchema);
929
+ return { toolSpec: {
930
+ name: tool.name,
931
+ description: tool.description,
932
+ inputSchema: { json: inputSchemaJson }
933
+ } };
934
+ }),
935
+ ...this.sessionConfig?.toolChoice && { toolChoice: this.sessionConfig.toolChoice }
936
+ };
937
+ else if (this.sessionConfig?.toolChoice) promptStartEvent.toolConfiguration = { toolChoice: this.sessionConfig.toolChoice };
938
+ eventQueue.push({ event: { promptStart: promptStartEvent } });
939
+ this.promptStarted = true;
940
+ const systemContentName = (0, crypto.randomUUID)();
941
+ eventQueue.push({ event: { contentStart: {
942
+ promptName,
943
+ contentName: systemContentName,
944
+ type: "TEXT",
945
+ interactive: false,
946
+ role: "SYSTEM",
947
+ textInputConfiguration: { mediaType: "text/plain" }
948
+ } } });
949
+ eventQueue.push({ event: { textInput: {
950
+ promptName,
951
+ contentName: systemContentName,
952
+ content: this.instructions || ""
953
+ } } });
954
+ eventQueue.push({ event: { contentEnd: {
955
+ promptName,
956
+ contentName: systemContentName
957
+ } } });
958
+ this.audioContentStarted = false;
959
+ this.log(`Queue pre-populated with ${eventQueue.length} event(s)`);
960
+ }
961
+ /**
962
+ * Issue the InvokeModelWithBidirectionalStreamCommand to AWS Bedrock with
963
+ * a 5-second abort timeout that tears down the client on hang to avoid
964
+ * leaked HTTP/2 sessions. On success the response stream is stored and the
965
+ * voice transitions to 'connected'.
966
+ */
967
+ async sendInitialConnectCommand(asyncIterable) {
968
+ if (!this.client) throw new NovaSonicError("connection_failed", "Bedrock client must be created before sending the initial command");
969
+ const command = new _aws_sdk_client_bedrock_runtime.InvokeModelWithBidirectionalStreamCommand({
970
+ modelId: this.model,
971
+ body: asyncIterable
972
+ });
973
+ const sendStartTime = Date.now();
974
+ const abortController = new AbortController();
975
+ const timeoutId = setTimeout(() => {
976
+ this.log("[DEBUG] client.send() timeout after 5 seconds - aborting request");
977
+ abortController.abort();
978
+ }, 5e3);
979
+ let response;
980
+ try {
981
+ response = await this.client.send(command, { abortSignal: abortController.signal });
982
+ } catch (error) {
983
+ const sendDuration = Date.now() - sendStartTime;
984
+ if (abortController.signal.aborted) {
985
+ this.log(`[DEBUG] client.send() aborted after ${sendDuration}ms`);
986
+ this._closeSignal?.();
987
+ this.client.destroy();
988
+ throw new Error("client.send() timeout");
989
+ }
990
+ this.log(`[DEBUG] client.send() error after ${sendDuration}ms:`, error);
991
+ throw error;
992
+ } finally {
993
+ clearTimeout(timeoutId);
994
+ }
995
+ const sendDuration = Date.now() - sendStartTime;
996
+ this.log(`[DEBUG] client.send() completed in ${sendDuration}ms`);
997
+ this.log("Received response from AWS Bedrock");
998
+ this.stream = response.body;
999
+ this.log(`[DEBUG] Response stream is async iterable: ${this.stream && typeof this.stream[Symbol.asyncIterator] === "function"}`);
1000
+ this.state = "connected";
1001
+ this.log(`[STATE] State set to 'connected'`);
1002
+ }
1003
+ /**
1004
+ * Process the bidirectional stream from AWS Bedrock
1005
+ */
1006
+ async processStream() {
1007
+ if (!this.stream) {
1008
+ this.log("[Stream] No stream available, cannot process");
1009
+ return;
1010
+ }
1011
+ if (this.processingStream) {
1012
+ this.log("[Stream] Already processing stream, skipping");
1013
+ return;
1014
+ }
1015
+ this.processingStream = true;
1016
+ this.log("[Stream] Starting stream processing");
1017
+ let eventCount = 0;
1018
+ let lastEventTime = Date.now();
1019
+ try {
1020
+ for await (const chunk of this.stream) if (chunk.chunk) {
1021
+ const textResponse = Buffer.from(chunk.chunk.bytes || []).toString("utf-8");
1022
+ eventCount++;
1023
+ const now = Date.now();
1024
+ const timeSinceLastEvent = now - lastEventTime;
1025
+ lastEventTime = now;
1026
+ this.log(`[Stream] Received chunk #${eventCount}, length: ${textResponse.length}, time since last: ${timeSinceLastEvent}ms`);
1027
+ try {
1028
+ const jsonResponse = JSON.parse(textResponse);
1029
+ this.log(`[Stream] ========================================`);
1030
+ this.log(`[Stream] Parsed JSON response, keys: ${Object.keys(jsonResponse).join(", ")}`);
1031
+ if (jsonResponse.event) {
1032
+ const eventKeys = Object.keys(jsonResponse.event);
1033
+ this.log(`[Stream] Event keys: ${eventKeys.join(", ")}`);
1034
+ if (jsonResponse.event.contentStart) {
1035
+ this.log(`[Stream] → Handling contentStart`);
1036
+ this.handleServerEvent({ contentStart: jsonResponse.event.contentStart });
1037
+ } else if (jsonResponse.event.textOutput) {
1038
+ this.log(`[Stream] → Handling textOutput, content length: ${jsonResponse.event.textOutput?.content?.length ?? 0}`);
1039
+ this.handleServerEvent({ textOutput: jsonResponse.event.textOutput });
1040
+ } else if (jsonResponse.event.audioOutput) this.handleServerEvent({ audioOutput: jsonResponse.event.audioOutput });
1041
+ else if (jsonResponse.event.toolUse) this.handleServerEvent({ toolUse: jsonResponse.event.toolUse });
1042
+ else if (jsonResponse.event.contentEnd && jsonResponse.event.contentEnd.type === "TOOL") this.handleServerEvent({ contentEnd: jsonResponse.event.contentEnd });
1043
+ else if (jsonResponse.event.contentEnd) {
1044
+ this.log(`[Stream] Found contentEnd, type: ${jsonResponse.event.contentEnd.type}, stopReason: ${jsonResponse.event.contentEnd.stopReason}`);
1045
+ this.handleServerEvent({ contentEnd: jsonResponse.event.contentEnd });
1046
+ } else if (jsonResponse.event.completionStart) {
1047
+ this.log("[Stream] Found completionStart inside event object:", JSON.stringify(jsonResponse.event.completionStart, null, 2));
1048
+ this.emit("completionStart", jsonResponse.event.completionStart);
1049
+ } else if (jsonResponse.event.completionEnd) {
1050
+ this.log("[Stream] Found completionEnd inside event object:", JSON.stringify(jsonResponse.event.completionEnd, null, 2));
1051
+ this.handleServerEvent({ completionEnd: jsonResponse.event.completionEnd });
1052
+ } else {
1053
+ const eventKeys = Object.keys(jsonResponse.event || {});
1054
+ this.log(`[Stream] Event keys for other events: ${eventKeys.join(", ")}`);
1055
+ if (eventKeys.length > 0) if (eventKeys.includes("completionEnd")) {
1056
+ this.log("[Stream] Found completionEnd in other events, handling explicitly");
1057
+ this.handleServerEvent({ completionEnd: jsonResponse.event.completionEnd });
1058
+ } else {
1059
+ const eventKey = eventKeys[0];
1060
+ this.log(`[Stream] Dispatching other event: ${eventKey}`);
1061
+ const eventValue = jsonResponse.event[eventKey];
1062
+ if (eventValue !== void 0) if (eventKey === "completionEnd") this.handleServerEvent({ completionEnd: eventValue });
1063
+ else this.handleServerEvent({ [eventKey]: eventValue });
1064
+ }
1065
+ else if (Object.keys(jsonResponse).length > 0) this.log(`[Stream] Unknown event structure, keys:`, Object.keys(jsonResponse).join(", "));
1066
+ }
1067
+ } else {
1068
+ if (this.debug) this.log("[Stream] Received event without \"event\" wrapper, keys:", Object.keys(jsonResponse).join(", "));
1069
+ if (jsonResponse.usageEvent) this.emit("usage", {
1070
+ inputTokens: jsonResponse.usageEvent.totalInputTokens || 0,
1071
+ outputTokens: jsonResponse.usageEvent.totalOutputTokens || 0,
1072
+ totalTokens: jsonResponse.usageEvent.totalTokens || 0
1073
+ });
1074
+ if (jsonResponse.completionEnd) {
1075
+ this.log("[Stream] Found completionEnd at top level:", JSON.stringify(jsonResponse.completionEnd, null, 2));
1076
+ this.handleServerEvent({ completionEnd: jsonResponse.completionEnd });
1077
+ }
1078
+ if (!jsonResponse.event && !jsonResponse.completionEnd && !jsonResponse.usageEvent) this.log("[Stream] Received response without event wrapper, keys:", Object.keys(jsonResponse).join(", "));
1079
+ if (jsonResponse.completionStart || jsonResponse.event?.completionStart) {
1080
+ const completionStart = jsonResponse.completionStart || jsonResponse.event.completionStart;
1081
+ this.log("[Stream] Found completionStart:", JSON.stringify(completionStart, null, 2));
1082
+ this.emit("completionStart", completionStart);
1083
+ }
1084
+ }
1085
+ } catch (parseError) {
1086
+ this.log("[Stream] Failed to parse JSON response:", textResponse.substring(0, 200));
1087
+ this.emit("error", {
1088
+ message: "Failed to parse stream response",
1089
+ code: "PARSE_ERROR",
1090
+ details: parseError
1091
+ });
1092
+ }
1093
+ } else if (chunk.internalServerException) this.emit("error", {
1094
+ message: "Internal server error",
1095
+ code: "INTERNAL_SERVER_ERROR",
1096
+ details: chunk.internalServerException
1097
+ });
1098
+ else if (chunk.modelStreamErrorException) this.emit("error", {
1099
+ message: "Model stream error",
1100
+ code: "MODEL_STREAM_ERROR",
1101
+ details: chunk.modelStreamErrorException
1102
+ });
1103
+ else if (chunk.modelTimeoutException) this.emit("error", {
1104
+ message: "Model timeout",
1105
+ code: "MODEL_TIMEOUT",
1106
+ details: chunk.modelTimeoutException
1107
+ });
1108
+ else if (chunk.serviceUnavailableException) this.emit("error", {
1109
+ message: "Service unavailable",
1110
+ code: "SERVICE_UNAVAILABLE",
1111
+ details: chunk.serviceUnavailableException
1112
+ });
1113
+ else if (chunk.throttlingException) this.emit("error", {
1114
+ message: "Request throttled",
1115
+ code: "THROTTLING",
1116
+ details: chunk.throttlingException
1117
+ });
1118
+ else if (chunk.validationException) this.emit("error", {
1119
+ message: "Validation error",
1120
+ code: "VALIDATION_ERROR",
1121
+ details: chunk.validationException
1122
+ });
1123
+ } catch (streamError) {
1124
+ this.log("[Stream] Error in processStream:", streamError);
1125
+ this.emit("error", {
1126
+ message: "Stream processing error",
1127
+ code: "STREAM_ERROR",
1128
+ details: streamError instanceof Error ? streamError.message : String(streamError)
1129
+ });
1130
+ } finally {
1131
+ this.processingStream = false;
1132
+ this.log(`[Stream] processStream finished, processingStream set to false. Total events received: ${eventCount || 0}`);
1133
+ this.log(`[Stream] Stream state: state=${this.state}, stream exists=${!!this.stream}`);
1134
+ if (!this.turnCompleted && this.audioContentStarted) {
1135
+ this.log("[Stream] Stream ended but turn not completed - signaling turn completion as fallback");
1136
+ this.log(`[Stream] State: turnCompleted=${this.turnCompleted}, audioContentStarted=${this.audioContentStarted}, hasSentContentEnd=${this.hasSentContentEnd}`);
1137
+ this.turnCompleted = true;
1138
+ this.emit("turnComplete", { timestamp: Date.now() });
1139
+ if (this.currentResponseId) {
1140
+ const stream$1 = this.speakerStreams.get(this.currentResponseId);
1141
+ if (stream$1) stream$1.end();
1142
+ this.speakerStreams.delete(this.currentResponseId);
1143
+ this.currentResponseId = void 0;
1144
+ }
1145
+ this.hasSentContentEnd = false;
1146
+ this.log("[Stream] Turn completion signaled, ready for next turn");
1147
+ } else if (this.turnCompleted) this.log("[Stream] Stream ended and turn was already completed");
1148
+ else this.log(`[Stream] Stream ended but turn not completed - audioContentStarted=${this.audioContentStarted}, turnCompleted=${this.turnCompleted}`);
1149
+ if (this.stream && this.state === "connected" && !this.processingStream && !this.streamRestartAttempted) {
1150
+ this.log("[Stream] Stream still open but processing stopped - will restart stream processing");
1151
+ this.streamRestartAttempted = true;
1152
+ setImmediate(() => {
1153
+ if (this.stream && this.state === "connected" && !this.processingStream) {
1154
+ this.log("[Stream] Restarting stream processing for subsequent turns");
1155
+ this.processStream().catch((error) => {
1156
+ this.log("[Stream] Error restarting stream processing:", error);
1157
+ this.streamRestartAttempted = false;
1158
+ });
1159
+ } else this.streamRestartAttempted = false;
1160
+ });
1161
+ } else if (this.streamRestartAttempted) this.log("[Stream] Stream restart already attempted, skipping");
1162
+ }
1163
+ }
1164
+ /**
1165
+ * Handle server events from AWS Bedrock
1166
+ */
1167
+ handleServerEvent(event) {
1168
+ if (this.debug) this.log("Received event, keys:", Object.keys(event).join(", "));
1169
+ if (event.contentStart) this.handleContentStart(event.contentStart);
1170
+ if (event.textOutput) this.handleTextOutput(event.textOutput);
1171
+ if (event.audioOutput?.content) this.handleAudioOutput(event.audioOutput);
1172
+ if (event.toolUse) this.handleToolUse(event.toolUse);
1173
+ if (event.contentEnd) this.handleContentEnd(event.contentEnd);
1174
+ if (event.completionEnd) this.handleCompletionEnd(event.completionEnd);
1175
+ if (event.error) this.emit("error", {
1176
+ message: event.error.message || "Unknown error",
1177
+ code: event.error.code || "UNKNOWN_ERROR",
1178
+ details: event.error
1179
+ });
1180
+ }
1181
+ /**
1182
+ * Handle a contentStart event. Tracks generationStage for text content
1183
+ * blocks so the corresponding 'writing' events can be tagged
1184
+ * SPECULATIVE/FINAL for the client.
1185
+ */
1186
+ handleContentStart(contentStart) {
1187
+ const role = contentStart.role?.toLowerCase();
1188
+ const contentType = contentStart.type;
1189
+ this.log(`[Event] contentStart: type=${contentType || "unknown"}, role=${role}`);
1190
+ this.emit("contentStart", contentStart);
1191
+ if (contentType === "TEXT" && contentStart.additionalModelFields) try {
1192
+ const additionalFields = JSON.parse(contentStart.additionalModelFields);
1193
+ this.currentTextGenerationStage = additionalFields.generationStage;
1194
+ this.log(`[Event] Text content generationStage: ${this.currentTextGenerationStage}`);
1195
+ } catch {
1196
+ this.currentTextGenerationStage = void 0;
1197
+ }
1198
+ else if (contentType === "TEXT") this.currentTextGenerationStage = void 0;
1199
+ }
1200
+ /**
1201
+ * Handle a textOutput event. Detects interruption (barge-in) markers in
1202
+ * the payload, otherwise emits a 'writing' event with the text and
1203
+ * current generationStage.
1204
+ */
1205
+ handleTextOutput(textOutput) {
1206
+ const text = textOutput.content || "";
1207
+ const role = textOutput.role?.toLowerCase() || "assistant";
1208
+ this.log(`[Event] textOutput received: role=${role}, text length=${text.length}`);
1209
+ let isInterrupted = false;
1210
+ try {
1211
+ const parsed = JSON.parse(text);
1212
+ if (parsed && parsed.interrupted === true) isInterrupted = true;
1213
+ } catch {
1214
+ if (/interrupted/i.test(text)) isInterrupted = true;
1215
+ }
1216
+ if (isInterrupted) {
1217
+ this.log(`[Event] Interrupt detected, emitting interrupt event`);
1218
+ this.emit("interrupt", {
1219
+ type: "user",
1220
+ timestamp: Date.now()
1221
+ });
1222
+ return;
1223
+ }
1224
+ const generationStage = this.currentTextGenerationStage;
1225
+ this.log(`[Event] Emitting 'writing': role=${role}, generationStage=${generationStage}, length=${text.length}`);
1226
+ this.emit("writing", {
1227
+ text,
1228
+ role,
1229
+ generationStage
1230
+ });
1231
+ }
1232
+ /**
1233
+ * Handle an audioOutput event. Decodes the base64 LPCM payload, emits
1234
+ * 'speaking' with both the base64 string and an Int16Array view, and
1235
+ * forwards bytes to any active speaker stream.
1236
+ */
1237
+ handleAudioOutput(audioOutput) {
1238
+ try {
1239
+ const content = audioOutput.content;
1240
+ const audioBytes = Buffer.from(content, "base64");
1241
+ this.log(`[Event] Audio output: ${audioBytes.length} bytes`);
1242
+ this.isReceivingAssistantAudio = true;
1243
+ const audioData = new Int16Array(audioBytes.buffer, audioBytes.byteOffset, audioBytes.byteLength / 2);
1244
+ this.emit("speaking", {
1245
+ audio: content,
1246
+ audioData,
1247
+ response_id: this.currentResponseId
1248
+ });
1249
+ if (this.currentResponseId) {
1250
+ const stream$2 = this.speakerStreams.get(this.currentResponseId);
1251
+ if (stream$2) stream$2.write(audioBytes);
1252
+ }
1253
+ } catch (error) {
1254
+ this.log("[Event] Error decoding audio:", error);
1255
+ this.emit("error", {
1256
+ message: "Failed to decode audio",
1257
+ code: "AUDIO_DECODE_ERROR",
1258
+ details: error
1259
+ });
1260
+ }
1261
+ }
1262
+ /**
1263
+ * Handle a toolUse event. Emits 'toolCall' and dispatches to the
1264
+ * configured tool's execute() function via handleToolCall().
1265
+ */
1266
+ handleToolUse(toolUse) {
1267
+ const toolUseId = toolUse.toolUseId || "";
1268
+ const toolName = toolUse.toolName || "";
1269
+ const toolInput = toolUse.input || {};
1270
+ this.emit("toolCall", {
1271
+ name: toolName,
1272
+ args: toolInput,
1273
+ id: toolUseId
1274
+ });
1275
+ if (this.tools && toolName in this.tools) this.handleToolCall(toolName, toolInput, toolUseId);
1276
+ }
1277
+ /**
1278
+ * Handle a contentEnd event. Forwards it to clients, then routes by
1279
+ * stopReason / type:
1280
+ * - INTERRUPTED: emit 'interrupt' and tear down the active speaker stream
1281
+ * - TOOL: end the active speaker stream
1282
+ * - AUDIO with END_TURN: signal turnComplete (assistant audio finished)
1283
+ * - AUDIO with PARTIAL_TURN while receiving assistant audio: schedule
1284
+ * fallback turnComplete in case completionEnd never arrives
1285
+ * - AUDIO otherwise: user input ended, reset turn flags
1286
+ */
1287
+ handleContentEnd(contentEnd) {
1288
+ this.log(`[Event] contentEnd received: type=${contentEnd.type}, stopReason=${contentEnd.stopReason}`);
1289
+ this.emit("contentEnd", contentEnd);
1290
+ if (contentEnd.stopReason === "INTERRUPTED") {
1291
+ this.log("[Event] Content interrupted by user (barge-in)");
1292
+ this.emit("interrupt", {
1293
+ type: "user",
1294
+ timestamp: Date.now()
1295
+ });
1296
+ if (this.currentResponseId) {
1297
+ const stream$3 = this.speakerStreams.get(this.currentResponseId);
1298
+ if (stream$3) stream$3.destroy();
1299
+ this.speakerStreams.delete(this.currentResponseId);
1300
+ }
1301
+ this.currentResponseId = void 0;
1302
+ this.log("[Event] After interruption, keeping audioContentStarted=true for continued streaming");
1303
+ } else if (contentEnd.type === "TOOL" && this.currentResponseId) {
1304
+ const stream$4 = this.speakerStreams.get(this.currentResponseId);
1305
+ if (stream$4) stream$4.end();
1306
+ } else if (contentEnd.type === "AUDIO") if (contentEnd.stopReason === "END_TURN") {
1307
+ this.log(`[Event] contentEnd (AUDIO) with stopReason END_TURN - signaling turn complete`);
1308
+ if (this.currentResponseId) {
1309
+ const stream$5 = this.speakerStreams.get(this.currentResponseId);
1310
+ if (stream$5) stream$5.end();
1311
+ this.speakerStreams.delete(this.currentResponseId);
1312
+ this.currentResponseId = void 0;
1313
+ }
1314
+ if (!this.turnCompleted) {
1315
+ this.turnCompleted = true;
1316
+ this.emit("turnComplete", { timestamp: Date.now() });
1317
+ this.hasSentContentEnd = false;
1318
+ this.log(`[Event] Turn complete (from contentEnd AUDIO with END_TURN), ready for next turn. audioContentStarted: ${this.audioContentStarted}, audioContentName: ${this.audioContentName}`);
1319
+ } else this.log(`[Event] contentEnd (AUDIO) with END_TURN received but turn already completed - skipping duplicate turnComplete emission`);
1320
+ if (!this.turnCompleteTimeout) this.turnCompleteTimeout = setTimeout(() => {
1321
+ this.log(`[Event] Timeout: completionEnd not received, but turn already completed from contentEnd`);
1322
+ this.turnCompleteTimeout = void 0;
1323
+ }, 1e3);
1324
+ } else if (this.isReceivingAssistantAudio && contentEnd.stopReason === "PARTIAL_TURN") {
1325
+ this.isReceivingAssistantAudio = false;
1326
+ if (!this.turnCompleteTimeout && !this.turnCompleted) {
1327
+ this.log(`[Event] contentEnd (AUDIO) with PARTIAL_TURN for assistant output - waiting for completionEnd, setting fallback timeout`);
1328
+ this.turnCompleteTimeout = setTimeout(() => {
1329
+ if (!this.turnCompleted) {
1330
+ this.log(`[Event] Fallback: completionEnd not received after contentEnd (AUDIO) with PARTIAL_TURN, signaling turn complete`);
1331
+ this.turnCompleted = true;
1332
+ this.emit("turnComplete", { timestamp: Date.now() });
1333
+ if (this.currentResponseId) {
1334
+ const stream$6 = this.speakerStreams.get(this.currentResponseId);
1335
+ if (stream$6) stream$6.end();
1336
+ this.speakerStreams.delete(this.currentResponseId);
1337
+ this.currentResponseId = void 0;
1338
+ }
1339
+ this.hasSentContentEnd = false;
1340
+ this.turnCompleteTimeout = void 0;
1341
+ }
1342
+ }, 2e3);
1343
+ }
1344
+ } else {
1345
+ this.hasSentContentEnd = false;
1346
+ this.turnCompleted = false;
1347
+ this.log(`[Event] contentEnd (AUDIO) - user input ended, stopReason: ${contentEnd.stopReason}. Keeping audioContentStarted=true for next turn. Reset hasSentContentEnd=false, turnCompleted=false.`);
1348
+ }
1349
+ else if (contentEnd.type === "TEXT") {
1350
+ this.currentTextGenerationStage = void 0;
1351
+ this.log(`[Event] contentEnd (TEXT) received, stopReason: ${contentEnd.stopReason}. Turn completion handled by completionEnd/contentEnd(AUDIO).`);
1352
+ if (contentEnd.stopReason === "END_TURN") this.hasSentContentEnd = false;
1353
+ }
1354
+ }
1355
+ /**
1356
+ * Handle a completionEnd event. AWS uses this as the definitive signal
1357
+ * that a turn (and all audio output) has finished. Tears down the active
1358
+ * speaker stream, clears any fallback timer, emits 'turnComplete' once,
1359
+ * and forwards token usage if reported.
1360
+ */
1361
+ handleCompletionEnd(completionEnd) {
1362
+ this.log(`[Event] completionEnd received, stopReason: ${completionEnd.stopReason}`);
1363
+ if (this.turnCompleteTimeout) {
1364
+ clearTimeout(this.turnCompleteTimeout);
1365
+ this.turnCompleteTimeout = void 0;
1366
+ }
1367
+ if (this.currentResponseId) {
1368
+ const stream$7 = this.speakerStreams.get(this.currentResponseId);
1369
+ if (stream$7) stream$7.end();
1370
+ this.speakerStreams.delete(this.currentResponseId);
1371
+ this.currentResponseId = void 0;
1372
+ }
1373
+ this.isReceivingAssistantAudio = false;
1374
+ if (!this.turnCompleted) {
1375
+ this.log(`[Event] completionEnd - signaling turn complete (stopReason: ${completionEnd.stopReason || "undefined"})`);
1376
+ this.turnCompleted = true;
1377
+ this.emit("turnComplete", { timestamp: Date.now() });
1378
+ this.hasSentContentEnd = false;
1379
+ } else this.log(`[Event] completionEnd received but turn already completed - skipping duplicate turnComplete emission`);
1380
+ if (completionEnd.usage) this.emit("usage", {
1381
+ inputTokens: completionEnd.usage.inputTokens || 0,
1382
+ outputTokens: completionEnd.usage.outputTokens || 0,
1383
+ totalTokens: (completionEnd.usage.inputTokens || 0) + (completionEnd.usage.outputTokens || 0)
1384
+ });
1385
+ }
1386
+ /**
1387
+ * Handle tool execution
1388
+ */
1389
+ async handleToolCall(toolName, args, toolUseId) {
1390
+ const tool = this.tools?.[toolName];
1391
+ if (!tool || !tool.execute) {
1392
+ this.emit("error", {
1393
+ message: `Tool ${toolName} not found or has no execute function`,
1394
+ code: "TOOL_NOT_FOUND"
1395
+ });
1396
+ return;
1397
+ }
1398
+ try {
1399
+ const result = await tool.execute({
1400
+ context: args,
1401
+ requestContext: this.requestContext
1402
+ }, {
1403
+ toolCallId: toolUseId,
1404
+ messages: []
1405
+ });
1406
+ await this.sendClientEvent({ toolResult: {
1407
+ toolUseId,
1408
+ content: [{ json: typeof result === "object" ? result : { result } }]
1409
+ } });
1410
+ } catch (error) {
1411
+ this.emit("error", {
1412
+ message: `Error executing tool ${toolName}: ${error instanceof Error ? error.message : "Unknown error"}`,
1413
+ code: "TOOL_EXECUTION_ERROR",
1414
+ details: error
1415
+ });
1416
+ await this.sendClientEvent({ toolResult: {
1417
+ toolUseId,
1418
+ content: [{ text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` }]
1419
+ } });
1420
+ }
1421
+ }
1422
+ /**
1423
+ * Send a client event to AWS Bedrock
1424
+ * Events are sent through the input stream that was passed to the bidirectional stream command
1425
+ */
1426
+ async sendClientEvent(event) {
1427
+ if (this.state !== "connected") throw new NovaSonicError("not_connected", "Not connected to AWS Bedrock. Call connect() first.");
1428
+ try {
1429
+ const eventQueue = this._eventQueue;
1430
+ const signalQueue = this._signalQueue;
1431
+ if (!eventQueue || !signalQueue) throw new NovaSonicError("not_connected", "Event queue not initialized. Connection may not be fully established.");
1432
+ this.log(`[sendClientEvent] Adding event to queue (queue size: ${eventQueue.length})`);
1433
+ eventQueue.push({ event });
1434
+ this.log(`[sendClientEvent] Event added, queue size now: ${eventQueue.length}, signaling...`);
1435
+ signalQueue();
1436
+ this.log(`[sendClientEvent] Signal sent`);
1437
+ if (this.debug) this.log("Sent client event, keys:", Object.keys(event).join(", "));
1438
+ } catch (error) {
1439
+ throw new NovaSonicError("websocket_error", `Failed to send client event: ${error instanceof Error ? error.message : "Unknown error"}`, error);
1440
+ }
1441
+ }
1442
+ /**
1443
+ * Disconnects from the AWS Bedrock session and cleans up resources.
1444
+ *
1445
+ * Pushes a `sessionEnd` event to the queue before signalling close,
1446
+ * then schedules client destruction on the next tick so the async
1447
+ * iterator has a chance to yield the event to the SDK.
1448
+ */
1449
+ close() {
1450
+ if (this.state === "disconnected") return;
1451
+ this.state = "disconnected";
1452
+ this.processingStream = false;
1453
+ if (this.turnCompleteTimeout) {
1454
+ clearTimeout(this.turnCompleteTimeout);
1455
+ this.turnCompleteTimeout = void 0;
1456
+ }
1457
+ const eventQueue = this._eventQueue;
1458
+ const signalQueue = this._signalQueue;
1459
+ if (eventQueue && signalQueue) {
1460
+ eventQueue.push({ event: { sessionEnd: {} } });
1461
+ signalQueue();
1462
+ }
1463
+ const closeSignal = this._closeSignal;
1464
+ if (closeSignal) closeSignal();
1465
+ if (this.inputStream) {
1466
+ this.inputStream.end();
1467
+ this.inputStream = void 0;
1468
+ }
1469
+ for (const stream$8 of this.speakerStreams.values()) stream$8.end();
1470
+ this.speakerStreams.clear();
1471
+ const client = this.client;
1472
+ this.client = void 0;
1473
+ this.stream = void 0;
1474
+ if (client) setImmediate(() => {
1475
+ if (typeof client.destroy === "function") client.destroy();
1476
+ });
1477
+ this.log("Disconnected from AWS Bedrock Nova 2 Sonic");
1478
+ }
1479
+ /**
1480
+ * Equips the voice instance with a set of instructions.
1481
+ */
1482
+ addInstructions(instructions) {
1483
+ this.instructions = instructions;
1484
+ }
1485
+ /**
1486
+ * Equips the voice instance with a set of tools.
1487
+ */
1488
+ addTools(tools) {
1489
+ this.tools = tools || {};
1490
+ }
1491
+ /**
1492
+ * Convert text to speech
1493
+ */
1494
+ async speak(input, _options) {
1495
+ if (this.state !== "connected") throw new NovaSonicError("not_connected", "Not connected. Call connect() first.");
1496
+ let text = "";
1497
+ if (typeof input !== "string") {
1498
+ const chunks = [];
1499
+ for await (const chunk of input) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
1500
+ text = Buffer.concat(chunks).toString("utf-8");
1501
+ } else text = input;
1502
+ if (text.trim().length === 0) throw new NovaSonicError("validation_error", "Input text is empty");
1503
+ this.currentResponseId = `response-${Date.now()}`;
1504
+ const speakerStream = new stream.PassThrough();
1505
+ speakerStream.id = this.currentResponseId;
1506
+ this.speakerStreams.set(this.currentResponseId, speakerStream);
1507
+ this.emit("speaker", speakerStream);
1508
+ const promptName = this._promptName;
1509
+ if (!promptName) throw new NovaSonicError("not_connected", "Prompt name not initialized. Connection may not be fully established.");
1510
+ if (!this.promptStarted) throw new NovaSonicError("invalid_state", "Prompt not started. This should not happen - prompt should be started during connection.");
1511
+ const contentName = (0, crypto.randomUUID)();
1512
+ await this.sendClientEvent({ contentStart: {
1513
+ promptName,
1514
+ contentName,
1515
+ type: "TEXT",
1516
+ interactive: true,
1517
+ role: "USER",
1518
+ textInputConfiguration: { mediaType: "text/plain" }
1519
+ } });
1520
+ await this.sendClientEvent({ textInput: {
1521
+ promptName,
1522
+ contentName,
1523
+ content: text
1524
+ } });
1525
+ await this.sendClientEvent({ contentEnd: {
1526
+ promptName,
1527
+ contentName
1528
+ } });
1529
+ }
1530
+ /**
1531
+ * Convert speech to text (transcription)
1532
+ * For Nova Sonic, this is the same as send() - both stream audio input
1533
+ */
1534
+ async listen(audioStream, _options) {
1535
+ if (audioStream && typeof audioStream === "object" && "read" in audioStream) await this.send(audioStream);
1536
+ else throw new NovaSonicError("invalid_audio_format", "Unsupported audio stream format for listen()");
1537
+ }
1538
+ /**
1539
+ * Streams audio data in real-time to the AWS Bedrock service.
1540
+ * Following AWS Nova 2 Sonic event sequence:
1541
+ * 1. contentStart (AUDIO, USER) - if not already sent
1542
+ * 2. audioInput events (one per chunk)
1543
+ * 3. contentEnd - when audio stream ends (handled separately via endAudioInput)
1544
+ */
1545
+ async send(audioData) {
1546
+ this.log(`[send] Current state: ${this.state}`);
1547
+ if (this.state !== "connected") {
1548
+ this.log(`[send] ERROR: State is '${this.state}', expected 'connected'`);
1549
+ throw new NovaSonicError("not_connected", `Not connected. Current state: ${this.state}. Call connect() first.`);
1550
+ }
1551
+ this.log(`[send] State check passed, proceeding with send`);
1552
+ if (!(audioData instanceof Int16Array) && !(audioData && typeof audioData === "object" && "read" in audioData)) throw new NovaSonicError("invalid_audio_format", "Unsupported audio data format");
1553
+ if (this.turnCompleted || this.hasSentContentEnd) {
1554
+ this.log(`[send] Starting new turn - resetting flags. turnCompleted=${this.turnCompleted}, hasSentContentEnd=${this.hasSentContentEnd}.`);
1555
+ const needNewContent = this.hasSentContentEnd;
1556
+ this.turnCompleted = false;
1557
+ this.hasSentContentEnd = false;
1558
+ this.streamRestartAttempted = false;
1559
+ if (needNewContent) {
1560
+ this.audioContentStarted = false;
1561
+ this.log(`[send] contentEnd was previously sent - will create new audio content container`);
1562
+ }
1563
+ this.log(`[send] State reset: turnCompleted=false, hasSentContentEnd=false, audioContentStarted=${this.audioContentStarted}`);
1564
+ }
1565
+ if (!this.promptStarted) this.promptStarted = true;
1566
+ const promptName = this._promptName;
1567
+ if (!promptName) throw new NovaSonicError("not_connected", "Prompt name not initialized. Connection may not be fully established.");
1568
+ if (!this.audioContentStarted) {
1569
+ const audioContentId = (0, crypto.randomUUID)();
1570
+ this.audioContentName = audioContentId;
1571
+ this.log(`[send] First audio send - sending AUDIO contentStart with contentName: ${audioContentId}`);
1572
+ await this.sendClientEvent({ contentStart: {
1573
+ promptName,
1574
+ contentName: audioContentId,
1575
+ type: "AUDIO",
1576
+ interactive: true,
1577
+ role: "USER",
1578
+ audioInputConfiguration: {
1579
+ mediaType: "audio/lpcm",
1580
+ sampleRateHertz: 16e3,
1581
+ sampleSizeBits: 16,
1582
+ channelCount: 1,
1583
+ encoding: "base64",
1584
+ audioType: "SPEECH"
1585
+ }
1586
+ } });
1587
+ this.audioContentStarted = true;
1588
+ this.log(`[send] AUDIO contentStart sent, ready to stream audio`);
1589
+ } else this.log(`[send] AUDIO contentStart already sent, sending audioInput chunks directly`);
1590
+ if (!this.audioContentName) throw new NovaSonicError("invalid_state", "Audio content name not initialized. This should not happen.");
1591
+ const contentName = this.audioContentName;
1592
+ if (audioData instanceof Int16Array) {
1593
+ const buffer = Buffer.from(audioData.buffer, audioData.byteOffset, audioData.byteLength);
1594
+ const base64Audio = buffer.toString("base64");
1595
+ this.log(`[send] Sending audioInput chunk, size: ${buffer.length} bytes, contentName: ${contentName}, turnCompleted: ${this.turnCompleted}, hasSentContentEnd: ${this.hasSentContentEnd}, audioContentStarted: ${this.audioContentStarted}, state: ${this.state}`);
1596
+ if (this.state !== "connected") {
1597
+ this.log(`[send] ERROR: State changed to '${this.state}' during send!`);
1598
+ throw new NovaSonicError("not_connected", `Connection lost during send. State: ${this.state}`);
1599
+ }
1600
+ await this.sendClientEvent({ audioInput: {
1601
+ promptName,
1602
+ contentName,
1603
+ content: base64Audio
1604
+ } });
1605
+ this.log(`[send] audioInput chunk sent successfully`);
1606
+ } else if (audioData && typeof audioData === "object" && "read" in audioData) {
1607
+ const stream$9 = audioData;
1608
+ for await (const chunk of stream$9) {
1609
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1610
+ const base64Audio = buffer.toString("base64");
1611
+ this.log(`[send] Sending audioInput chunk from stream, size: ${buffer.length} bytes, contentName: ${contentName}, turnCompleted: ${this.turnCompleted}, hasSentContentEnd: ${this.hasSentContentEnd}`);
1612
+ await this.sendClientEvent({ audioInput: {
1613
+ promptName,
1614
+ contentName,
1615
+ content: base64Audio
1616
+ } });
1617
+ }
1618
+ } else throw new NovaSonicError("invalid_audio_format", "Unsupported audio data format");
1619
+ }
1620
+ /**
1621
+ * End audio input stream (sends contentEnd for audio)
1622
+ * Call this when done sending audio chunks
1623
+ */
1624
+ async endAudioInput() {
1625
+ if (this.hasSentContentEnd) {
1626
+ this.log("[endAudioInput] contentEnd already sent for this turn, skipping");
1627
+ return;
1628
+ }
1629
+ if (this.turnCompleted) {
1630
+ this.log("[endAudioInput] Turn already completed by AWS, skipping contentEnd. Resetting turnCompleted flag for next turn.");
1631
+ this.turnCompleted = false;
1632
+ this.hasSentContentEnd = false;
1633
+ return;
1634
+ }
1635
+ if (this.audioContentStarted && this.audioContentName && this._promptName) {
1636
+ const promptName = this._promptName;
1637
+ this.log("[endAudioInput] Sending contentEnd for audio input");
1638
+ await this.sendClientEvent({ contentEnd: {
1639
+ promptName,
1640
+ contentName: this.audioContentName
1641
+ } });
1642
+ this.hasSentContentEnd = true;
1643
+ } else this.log("[endAudioInput] Cannot send contentEnd: audioContentStarted=" + this.audioContentStarted + ", audioContentName=" + this.audioContentName);
1644
+ }
1645
+ /**
1646
+ * Register an event listener
1647
+ */
1648
+ on(event, callback) {
1649
+ if (!this.events[event]) this.events[event] = [];
1650
+ this.events[event].push(callback);
1651
+ }
1652
+ /**
1653
+ * Remove an event listener
1654
+ */
1655
+ off(event, callback) {
1656
+ if (!this.events[event]) return;
1657
+ const index = this.events[event].indexOf(callback);
1658
+ if (index !== -1) this.events[event].splice(index, 1);
1659
+ }
1660
+ /**
1661
+ * Emit an event with arguments
1662
+ */
1663
+ emit(event, data) {
1664
+ if (!this.events[event]) {
1665
+ this.log(`[NovaSonic] emit('${event}'): No listeners registered for this event`);
1666
+ return;
1667
+ }
1668
+ const listenerCount = this.events[event].length;
1669
+ this.log(`[NovaSonic] emit('${event}'): Calling ${listenerCount} listener(s)`);
1670
+ for (const callback of this.events[event]) try {
1671
+ callback(data);
1672
+ this.log(`[NovaSonic] emit('${event}'): Successfully called one listener`);
1673
+ } catch (error) {
1674
+ this.log(`Error in event handler for ${event}:`, error);
1675
+ }
1676
+ this.log(`[NovaSonic] emit('${event}'): Finished calling all ${listenerCount} listener(s)`);
1677
+ }
1678
+ /**
1679
+ * Get listener status
1680
+ */
1681
+ async getListener() {
1682
+ return { enabled: this.state === "connected" };
1683
+ }
1684
+ /**
1685
+ * Log helper
1686
+ */
1687
+ log(...args) {
1688
+ if (this.debug) console.info("[NovaSonicVoice]", ...args);
1689
+ }
1871
1690
  };
1872
-
1691
+ //#endregion
1873
1692
  exports.NovaSonicError = NovaSonicError;
1874
1693
  exports.NovaSonicErrorCode = NovaSonicErrorCode;
1875
1694
  exports.NovaSonicVoice = NovaSonicVoice;
1876
- //# sourceMappingURL=index.cjs.map
1695
+
1877
1696
  //# sourceMappingURL=index.cjs.map