@eka-care/medassist-core 1.0.65 → 1.0.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/dist/Synapse.d.ts +0 -1
  2. package/dist/Synapse.js +2 -25
  3. package/dist/connection/ConnectionFactory.d.ts +0 -1
  4. package/dist/connection/SSE.d.ts +0 -1
  5. package/dist/connection/Websocket.d.ts +0 -1
  6. package/dist/constants/index.d.ts +0 -1
  7. package/dist/constants/types.d.ts +0 -1
  8. package/dist/conversation.d.ts +0 -1
  9. package/dist/esm/Synapse.js +612 -0
  10. package/dist/esm/connection/ConnectionFactory.js +27 -0
  11. package/dist/esm/connection/SSE.js +212 -0
  12. package/dist/esm/connection/Websocket.js +178 -0
  13. package/dist/esm/constants/index.js +25 -0
  14. package/dist/esm/constants/types.js +1 -0
  15. package/dist/esm/conversation.js +7 -0
  16. package/dist/esm/events/Events.js +41 -0
  17. package/dist/esm/events/Incoming.js +1 -0
  18. package/dist/esm/events/Outgoing.js +1 -0
  19. package/dist/esm/events/index.js +2 -0
  20. package/dist/esm/events/types.js +5 -0
  21. package/dist/esm/index.js +34 -0
  22. package/dist/esm/internal/Api/BaseResource.js +50 -0
  23. package/dist/esm/internal/Api/HttpClient.js +131 -0
  24. package/dist/esm/internal/Api/types.js +1 -0
  25. package/dist/esm/internal/Error/Error.js +229 -0
  26. package/dist/esm/internal/Error/types.js +9 -0
  27. package/dist/esm/internal/connection/BaseConnection.js +134 -0
  28. package/dist/esm/internal/connection/types.js +17 -0
  29. package/dist/esm/internal/events/EventEmitter.js +26 -0
  30. package/dist/esm/internal/store/index.js +5 -0
  31. package/dist/esm/media/audio/Audio.copy.js +363 -0
  32. package/dist/esm/media/audio/Audio.js +310 -0
  33. package/dist/esm/media/audio/types.js +13 -0
  34. package/dist/esm/media/file/File.js +159 -0
  35. package/dist/esm/messages/MessageManager.js +476 -0
  36. package/dist/esm/messages/types.js +35 -0
  37. package/dist/esm/resources/config/Config.js +11 -0
  38. package/dist/esm/resources/feedback/Feedback.js +9 -0
  39. package/dist/esm/resources/feedback/types.js +7 -0
  40. package/dist/esm/resources/index.js +152 -0
  41. package/dist/esm/resources/session/Session.js +44 -0
  42. package/dist/esm/resources/session/types.js +5 -0
  43. package/dist/esm/resources/toolCall/ToolCall.js +12 -0
  44. package/dist/esm/resources/toolCall/types.js +34 -0
  45. package/dist/esm/resources/types.js +4 -0
  46. package/dist/esm/resources/voice/VoiceResource.js +14 -0
  47. package/dist/esm/resources/voice/types.js +1 -0
  48. package/dist/esm/types/index.js +8 -0
  49. package/dist/esm/utils/Error.js +110 -0
  50. package/dist/esm/voice/VoiceAgent.js +305 -0
  51. package/dist/esm/voice/VoiceAudioAnalyser.js +32 -0
  52. package/dist/esm/voice/index.js +1 -0
  53. package/dist/esm/voice/types.js +15 -0
  54. package/dist/events/Events.d.ts +0 -1
  55. package/dist/events/Incoming.d.ts +0 -1
  56. package/dist/events/Outgoing.d.ts +0 -1
  57. package/dist/events/index.d.ts +0 -1
  58. package/dist/events/types.d.ts +0 -1
  59. package/dist/index.d.ts +0 -1
  60. package/dist/internal/Api/BaseResource.d.ts +0 -1
  61. package/dist/internal/Api/HttpClient.d.ts +0 -1
  62. package/dist/internal/Api/types.d.ts +0 -1
  63. package/dist/internal/Error/Error.d.ts +0 -1
  64. package/dist/internal/Error/types.d.ts +0 -1
  65. package/dist/internal/connection/BaseConnection.d.ts +0 -1
  66. package/dist/internal/connection/types.d.ts +0 -1
  67. package/dist/internal/events/EventEmitter.d.ts +0 -1
  68. package/dist/internal/store/index.d.ts +0 -1
  69. package/dist/media/audio/Audio.copy.d.ts +0 -1
  70. package/dist/media/audio/Audio.d.ts +0 -1
  71. package/dist/media/audio/types.d.ts +0 -1
  72. package/dist/media/file/File.d.ts +0 -1
  73. package/dist/messages/MessageManager.d.ts +4 -2
  74. package/dist/messages/MessageManager.js +29 -5
  75. package/dist/messages/types.d.ts +0 -1
  76. package/dist/resources/config/Config.d.ts +0 -1
  77. package/dist/resources/feedback/Feedback.d.ts +0 -1
  78. package/dist/resources/feedback/types.d.ts +0 -1
  79. package/dist/resources/index.d.ts +0 -1
  80. package/dist/resources/session/Session.d.ts +0 -1
  81. package/dist/resources/session/types.d.ts +0 -1
  82. package/dist/resources/toolCall/ToolCall.d.ts +0 -1
  83. package/dist/resources/toolCall/types.d.ts +0 -1
  84. package/dist/resources/types.d.ts +0 -1
  85. package/dist/resources/voice/VoiceResource.d.ts +0 -1
  86. package/dist/resources/voice/types.d.ts +0 -1
  87. package/dist/types/index.d.ts +0 -1
  88. package/dist/utils/Error.d.ts +0 -1
  89. package/dist/voice/VoiceAgent.d.ts +0 -1
  90. package/dist/voice/VoiceAudioAnalyser.d.ts +0 -1
  91. package/dist/voice/index.d.ts +0 -1
  92. package/dist/voice/types.d.ts +0 -1
  93. package/package.json +4 -2
  94. package/dist/Synapse.d.ts.map +0 -1
  95. package/dist/auth/constants.d.ts +0 -12
  96. package/dist/auth/constants.d.ts.map +0 -1
  97. package/dist/auth/constants.js +0 -10
  98. package/dist/auth/index.d.ts +0 -3
  99. package/dist/auth/index.d.ts.map +0 -1
  100. package/dist/auth/index.js +0 -18
  101. package/dist/auth/session.d.ts +0 -5
  102. package/dist/auth/session.d.ts.map +0 -1
  103. package/dist/auth/session.js +0 -36
  104. package/dist/connection/ConnectionFactory.d.ts.map +0 -1
  105. package/dist/connection/SSE.d.ts.map +0 -1
  106. package/dist/connection/Websocket.d.ts.map +0 -1
  107. package/dist/constants/index.d.ts.map +0 -1
  108. package/dist/constants/types.d.ts.map +0 -1
  109. package/dist/conversation.d.ts.map +0 -1
  110. package/dist/events/Events.d.ts.map +0 -1
  111. package/dist/events/Incoming.d.ts.map +0 -1
  112. package/dist/events/Outgoing.d.ts.map +0 -1
  113. package/dist/events/index.d.ts.map +0 -1
  114. package/dist/events/types.d.ts.map +0 -1
  115. package/dist/index.d.ts.map +0 -1
  116. package/dist/internal/Api/BaseResource.d.ts.map +0 -1
  117. package/dist/internal/Api/HttpClient.d.ts.map +0 -1
  118. package/dist/internal/Api/types.d.ts.map +0 -1
  119. package/dist/internal/Error/Error.d.ts.map +0 -1
  120. package/dist/internal/Error/types.d.ts.map +0 -1
  121. package/dist/internal/connection/BaseConnection.d.ts.map +0 -1
  122. package/dist/internal/connection/types.d.ts.map +0 -1
  123. package/dist/internal/events/EventEmitter.d.ts.map +0 -1
  124. package/dist/internal/store/index.d.ts.map +0 -1
  125. package/dist/media/audio/Audio.copy.d.ts.map +0 -1
  126. package/dist/media/audio/Audio.d.ts.map +0 -1
  127. package/dist/media/audio/types.d.ts.map +0 -1
  128. package/dist/media/file/File.d.ts.map +0 -1
  129. package/dist/messages/MessageManager.d.ts.map +0 -1
  130. package/dist/messages/types.d.ts.map +0 -1
  131. package/dist/resources/config/Config.d.ts.map +0 -1
  132. package/dist/resources/feedback/Feedback.d.ts.map +0 -1
  133. package/dist/resources/feedback/types.d.ts.map +0 -1
  134. package/dist/resources/index.d.ts.map +0 -1
  135. package/dist/resources/session/Session.d.ts.map +0 -1
  136. package/dist/resources/session/types.d.ts.map +0 -1
  137. package/dist/resources/toolCall/ToolCall.d.ts.map +0 -1
  138. package/dist/resources/toolCall/types.d.ts.map +0 -1
  139. package/dist/resources/types.d.ts.map +0 -1
  140. package/dist/resources/voice/VoiceResource.d.ts.map +0 -1
  141. package/dist/resources/voice/types.d.ts.map +0 -1
  142. package/dist/types/index.d.ts.map +0 -1
  143. package/dist/utils/Error.d.ts.map +0 -1
  144. package/dist/voice/VoiceAgent.d.ts.map +0 -1
  145. package/dist/voice/VoiceAudioAnalyser.d.ts.map +0 -1
  146. package/dist/voice/index.d.ts.map +0 -1
  147. package/dist/voice/types.d.ts.map +0 -1
@@ -0,0 +1,363 @@
1
+ import { RecordingError, SynapseError } from "../../internal/Error/Error";
2
+ export class AudioManager {
3
+ mediaRecorder = null;
4
+ mediaStream = null;
5
+ recordingStartTime = 0;
6
+ autoPauseTimer = null;
7
+ config;
8
+ onAudioData = null;
9
+ onAudioError = null;
10
+ suppressCallbacks = false; //gurad against late recorder events like ondatavaailable after cancel () or cleanup() is called
11
+ constructor(config = {}) {
12
+ this.config = {
13
+ mimeType: "audio/webm;codecs=opus",
14
+ audioBitsPerSecond: 128000,
15
+ maxRecordingDuration: 900000, // 15 minutes
16
+ autoPauseEnabled: true,
17
+ ...config,
18
+ };
19
+ }
20
+ /**
21
+ * Best-effort permission check.
22
+ *
23
+ * NOTE: On some environments (especially iOS Safari / WKWebView),
24
+ * the Permissions API is missing or incomplete for "microphone".
25
+ * In those cases we **skip** the explicit check and rely on
26
+ * `getUserMedia` to trigger the OS permission prompt.
27
+ */
28
+ async checkCurrentPermissionState() {
29
+ try {
30
+ // Guard: Permissions API not supported or not fully implemented
31
+ if (typeof navigator === "undefined" ||
32
+ !("permissions" in navigator) ||
33
+ !navigator.permissions ||
34
+ typeof navigator.permissions.query !== "function") {
35
+ // Treat as "prompt" – let getUserMedia drive the permission flow.
36
+ return "prompt";
37
+ }
38
+ // Try to query microphone permission
39
+ // On iOS WebView, this may throw "query does not support this api"
40
+ const permission = await navigator.permissions.query({
41
+ name: "microphone",
42
+ });
43
+ if (permission.state === "denied") {
44
+ throw new RecordingError("Microphone permission denied", {
45
+ context: { permissionState: permission.state },
46
+ hint: "Ensure the browser or device has microphone access enabled for this site/app.",
47
+ });
48
+ }
49
+ return permission.state;
50
+ }
51
+ catch (error) {
52
+ // Some browsers (notably iOS WebKit/WKWebView) throw for unsupported permission names
53
+ // or return errors like "Permission :: query doesnot support" or "query does not support this api".
54
+ // Fall back to letting getUserMedia handle prompting.
55
+ const errorMessage = (error instanceof Error ? error.message : String(error)).toLowerCase();
56
+ if (errorMessage.includes("does not support") ||
57
+ errorMessage.includes("doesnot support") ||
58
+ errorMessage.includes("not supported") ||
59
+ errorMessage.includes("query does not support") ||
60
+ errorMessage.includes("permission::query") ||
61
+ errorMessage.includes("permission :: query doesnot support")) {
62
+ // Silently skip permission check for unsupported APIs - this is expected on iOS WebView
63
+ return "prompt";
64
+ }
65
+ // For other errors, log a warning but still proceed
66
+ console.warn("Permissions API for microphone is unavailable, falling back to getUserMedia-only flow:", error);
67
+ return "prompt";
68
+ }
69
+ }
70
+ async start(onAudioData, onAudioError //Clearly handles the error inside the callback without unhandled rejections
71
+ ) {
72
+ try {
73
+ // Check if mediaDevices is available
74
+ if (!navigator.mediaDevices) {
75
+ throw new RecordingError("Media devices API is not available. This usually means the page is not served over HTTPS or there are browser restrictions.", {
76
+ context: { feature: "mediaDevices" },
77
+ hint: "Serve the app over HTTPS and verify browser/device microphone support.",
78
+ displayMessage: "Microphone access is unavailable."
79
+ });
80
+ }
81
+ // Check if MediaRecorder is supported
82
+ if (!window.MediaRecorder) {
83
+ throw new RecordingError("MediaRecorder is not supported in this browser", {
84
+ context: { feature: "MediaRecorder" },
85
+ hint: "Use a browser that supports the MediaRecorder API.",
86
+ displayMessage: "Microphone access is unavailable."
87
+ });
88
+ }
89
+ // Check for supported audio formats in order of preference
90
+ const supportedTypes = [
91
+ "audio/mp3",
92
+ "audio/ogg;codecs=opus",
93
+ "audio/mp4",
94
+ "audio/ogg",
95
+ "audio/wav",
96
+ ].filter((type) => MediaRecorder.isTypeSupported(type));
97
+ if (supportedTypes.length === 0) {
98
+ throw new RecordingError("No supported audio formats found in this browser", {
99
+ context: { requestedTypes: supportedTypes },
100
+ hint: "Verify codec support or adjust the requested MIME types.",
101
+ displayMessage: "Microphone access is unavailable."
102
+ });
103
+ }
104
+ // Update config with best supported mime type
105
+ this.config.mimeType = supportedTypes[0];
106
+ await this.checkCurrentPermissionState();
107
+ this.onAudioData = onAudioData;
108
+ this.onAudioError = onAudioError ?? null;
109
+ this.suppressCallbacks = false; //reset the guard against late recorder events
110
+ // Get user media with fallback for iOS WebView compatibility
111
+ // iOS WebView may throw "query does not support this api" for specific audio constraints
112
+ try {
113
+ this.mediaStream = await navigator.mediaDevices.getUserMedia({
114
+ audio: {
115
+ echoCancellation: false,
116
+ noiseSuppression: false,
117
+ autoGainControl: false,
118
+ sampleRate: 44100,
119
+ channelCount: 1,
120
+ },
121
+ });
122
+ }
123
+ catch (constraintError) {
124
+ const errorMessage = (constraintError instanceof Error ? constraintError.message : String(constraintError)).toLowerCase();
125
+ // If the error indicates unsupported constraints (common on iOS WebView),
126
+ // fall back to minimal audio constraints
127
+ // Error formats: "Permission :: query doesnot support", "query does not support", etc.
128
+ if (errorMessage.includes("does not support") ||
129
+ errorMessage.includes("doesnot support") ||
130
+ errorMessage.includes("not supported") ||
131
+ errorMessage.includes("query does not support") ||
132
+ errorMessage.includes("permission :: query") ||
133
+ errorMessage.includes("permission::query") ||
134
+ errorMessage.includes("constraintnotsatisfiederror")) {
135
+ // Fall back to simplest audio constraint for iOS WebView compatibility
136
+ this.mediaStream = await navigator.mediaDevices.getUserMedia({
137
+ audio: true,
138
+ });
139
+ }
140
+ else {
141
+ // Re-throw other errors (permission denied, etc.)
142
+ if (errorMessage.includes("permission::query") || errorMessage.includes("permission :: query")) {
143
+ throw new RecordingError("Microphone permission denied", {
144
+ context: { permissionState: "denied" },
145
+ hint: `Please grant microphone access to the website. You can change this in the browser settings.`,
146
+ });
147
+ }
148
+ throw constraintError;
149
+ }
150
+ }
151
+ // Create MediaRecorder with fallback if the selected format fails
152
+ this.mediaRecorder = new MediaRecorder(this.mediaStream, {
153
+ mimeType: this.config.mimeType,
154
+ audioBitsPerSecond: this.config.audioBitsPerSecond,
155
+ });
156
+ // Verify MediaRecorder is in a valid state
157
+ if (this.mediaRecorder.state !== "inactive") {
158
+ throw new RecordingError("MediaRecorder is in an invalid state for start()", {
159
+ context: { recorderState: this.mediaRecorder.state },
160
+ displayMessage: "Failed to start recording"
161
+ });
162
+ }
163
+ // Set up event handlers
164
+ this.setupMediaRecorderEvents();
165
+ // Start recording
166
+ this.mediaRecorder.start();
167
+ this.recordingStartTime = Date.now();
168
+ // Set up auto-pause timer if enabled
169
+ if (this.config.autoPauseEnabled) {
170
+ this.setupAutoPauseTimer();
171
+ }
172
+ }
173
+ catch (error) {
174
+ throw this.toRecordingError(error, "Failed to start audio recording", {
175
+ stage: "start",
176
+ });
177
+ }
178
+ }
179
+ async processAudioChunk(blob) {
180
+ try {
181
+ const base64Audio = await this.blobToBase64(blob);
182
+ const now = Date.now();
183
+ if (!this.onAudioData) {
184
+ return;
185
+ }
186
+ const audioData = {
187
+ audio: base64Audio,
188
+ format: this.config.mimeType,
189
+ duration: this.recordingStartTime > 0 ? now - this.recordingStartTime : 0,
190
+ timestamp: this.recordingStartTime > 0 ? this.recordingStartTime : now,
191
+ };
192
+ this.onAudioData(audioData);
193
+ }
194
+ catch (error) {
195
+ this.handleError(error, { stage: "process_audio_chunk" });
196
+ }
197
+ }
198
+ toRecordingError(error, fallbackMessage, context) {
199
+ if (error instanceof RecordingError) {
200
+ return error;
201
+ }
202
+ if (error instanceof SynapseError) {
203
+ return new RecordingError(error.message, {
204
+ cause: error,
205
+ context: { ...error.context, ...context },
206
+ hint: error.hint,
207
+ });
208
+ }
209
+ const message = error instanceof Error && error.message ? error.message : fallbackMessage;
210
+ return new RecordingError(message, {
211
+ cause: error,
212
+ context,
213
+ });
214
+ }
215
+ handleError(error, context) {
216
+ const recordingError = this.toRecordingError(error, "Unexpected recording error", context);
217
+ if (this.onAudioError) {
218
+ try {
219
+ this.onAudioError(recordingError);
220
+ }
221
+ catch (callbackError) {
222
+ console.error("Audio error callback failed:", callbackError);
223
+ }
224
+ }
225
+ else {
226
+ console.error("AudioManager error:", recordingError);
227
+ }
228
+ }
229
+ /**
230
+ * Set up auto-pause timer for 15-minute limit
231
+ */
232
+ setupAutoPauseTimer() {
233
+ if (this.autoPauseTimer) {
234
+ clearTimeout(this.autoPauseTimer);
235
+ }
236
+ this.autoPauseTimer = setTimeout(() => {
237
+ this.stop();
238
+ }, this.config.maxRecordingDuration);
239
+ }
240
+ setupMediaRecorderEvents() {
241
+ if (!this.mediaRecorder)
242
+ return;
243
+ this.mediaRecorder.ondataavailable = (event) => {
244
+ if (this.suppressCallbacks || event.data.size <= 0) {
245
+ return;
246
+ }
247
+ this.processAudioChunk(event.data).catch((error) => this.handleError(error, {
248
+ stage: "media_recorder_ondataavailable",
249
+ hasData: event.data.size > 0,
250
+ }));
251
+ };
252
+ this.mediaRecorder.onstop = async () => {
253
+ this.recordingStartTime = 0;
254
+ };
255
+ this.mediaRecorder.onerror = (event) => {
256
+ this.handleError(event.error instanceof Error
257
+ ? event.error
258
+ : new Error("MediaRecorder encountered an unknown error"), { stage: "media_recorder_onerror" });
259
+ };
260
+ this.mediaRecorder.onstart = () => {
261
+ };
262
+ }
263
+ /**
264
+ * Convert blob to base64 string
265
+ */
266
+ blobToBase64(blob) {
267
+ return new Promise((resolve, reject) => {
268
+ const reader = new FileReader();
269
+ reader.onload = () => {
270
+ if (typeof reader.result === "string") {
271
+ // Remove data URL prefix (e.g., "data:audio/mp3;base64,")
272
+ const base64 = reader.result.split(",")[1];
273
+ resolve(base64);
274
+ }
275
+ else {
276
+ reject(new RecordingError("Failed to convert blob to base64", {
277
+ context: { stage: "blob_to_base64" },
278
+ }));
279
+ }
280
+ };
281
+ reader.onerror = () => reject(new RecordingError("FileReader error while converting audio chunk to base64", {
282
+ cause: reader.error ?? undefined,
283
+ context: { stage: "blob_to_base64" },
284
+ }));
285
+ reader.readAsDataURL(blob);
286
+ });
287
+ }
288
+ /**
289
+ * Stop recording manually
290
+ */
291
+ stop() {
292
+ if (!this.mediaRecorder)
293
+ return;
294
+ try {
295
+ // Clear auto-pause timer
296
+ if (this.autoPauseTimer) {
297
+ clearTimeout(this.autoPauseTimer);
298
+ this.autoPauseTimer = null;
299
+ }
300
+ // Stop MediaRecorder
301
+ if (this.mediaRecorder.state === "recording") {
302
+ this.mediaRecorder.stop();
303
+ }
304
+ // Stop media stream
305
+ if (this.mediaStream) {
306
+ this.mediaStream.getTracks().forEach((track) => track.stop());
307
+ this.mediaStream = null;
308
+ }
309
+ }
310
+ catch (error) {
311
+ throw this.toRecordingError(error, "Failed to stop audio recording", {
312
+ stage: "stop",
313
+ });
314
+ }
315
+ }
316
+ /**
317
+ * Cancel recording (discard data and suppress callbacks)
318
+ */
319
+ cancel() {
320
+ if (!this.mediaRecorder)
321
+ return;
322
+ try {
323
+ this.suppressCallbacks = true;
324
+ if (this.autoPauseTimer) {
325
+ clearTimeout(this.autoPauseTimer);
326
+ this.autoPauseTimer = null;
327
+ }
328
+ // Prevent ondataavailable from pushing chunks
329
+ this.mediaRecorder.ondataavailable = null;
330
+ // Stop MediaRecorder
331
+ if (this.mediaRecorder.state === "recording") {
332
+ this.mediaRecorder.stop();
333
+ }
334
+ // Stop media stream
335
+ if (this.mediaStream) {
336
+ this.mediaStream.getTracks().forEach((track) => track.stop());
337
+ this.mediaStream = null;
338
+ }
339
+ }
340
+ catch (error) {
341
+ throw this.toRecordingError(error, "Failed to cancel audio recording", {
342
+ stage: "cancel",
343
+ });
344
+ }
345
+ }
346
+ destroy() {
347
+ try {
348
+ this.cancel();
349
+ if (this.mediaRecorder) {
350
+ this.mediaRecorder = null;
351
+ }
352
+ if (this.mediaStream) {
353
+ this.mediaStream.getTracks().forEach((track) => track.stop());
354
+ this.mediaStream = null;
355
+ }
356
+ }
357
+ catch (error) {
358
+ throw this.toRecordingError(error, "Failed to destroy audio resources", {
359
+ stage: "destroy",
360
+ });
361
+ }
362
+ }
363
+ }
@@ -0,0 +1,310 @@
1
+ import { RecordingError, SynapseError } from "../../internal/Error/Error";
2
+ export class AudioManager {
3
+ mediaRecorder = null;
4
+ mediaStream = null;
5
+ recordingStartTime = 0;
6
+ autoPauseTimer = null;
7
+ config;
8
+ onAudioData = null;
9
+ onAudioError = null;
10
+ suppressCallbacks = false; //gurad against late recorder events like ondatavaailable after cancel () or cleanup() is called
11
+ constructor(config = {}) {
12
+ this.config = {
13
+ mimeType: "audio/webm;codecs=opus",
14
+ audioBitsPerSecond: 128000,
15
+ maxRecordingDuration: 900000, // 15 minutes
16
+ autoPauseEnabled: true,
17
+ ...config,
18
+ };
19
+ }
20
+ async checkCurrentPermissionState() {
21
+ if (!navigator.permissions || !navigator.permissions.query) {
22
+ return "prompt";
23
+ }
24
+ try {
25
+ const permission = await navigator.permissions.query({
26
+ name: "microphone",
27
+ });
28
+ if (permission.state === "denied") {
29
+ throw new RecordingError("Microphone permission denied", {
30
+ context: { permissionState: permission.state },
31
+ hint: "Ensure the browser has microphone access enabled for this site.",
32
+ });
33
+ }
34
+ return permission.state;
35
+ }
36
+ catch (error) {
37
+ if (error instanceof Error && error.name === "NotSupportedError")
38
+ return "prompt";
39
+ }
40
+ return "prompt";
41
+ }
42
+ async start(onAudioData, onAudioError //Clearly handles the error inside the callback without unhandled rejections
43
+ ) {
44
+ try {
45
+ // Check if mediaDevices is available
46
+ if (!navigator.mediaDevices) {
47
+ throw new RecordingError("Media devices API is not available. This usually means the page is not served over HTTPS or there are browser restrictions.", {
48
+ context: { feature: "mediaDevices" },
49
+ hint: "Serve the app over HTTPS and verify browser/device microphone support.",
50
+ displayMessage: "Microphone access is unavailable."
51
+ });
52
+ }
53
+ // Check if MediaRecorder is supported
54
+ if (!window.MediaRecorder) {
55
+ throw new RecordingError("MediaRecorder is not supported in this browser", {
56
+ context: { feature: "MediaRecorder" },
57
+ hint: "Use a browser that supports the MediaRecorder API.",
58
+ displayMessage: "Microphone access is unavailable."
59
+ });
60
+ }
61
+ // Check for supported audio formats in order of preference
62
+ const supportedTypes = [
63
+ "audio/mp3",
64
+ "audio/ogg;codecs=opus",
65
+ "audio/mp4",
66
+ "audio/ogg",
67
+ "audio/wav",
68
+ ].filter((type) => MediaRecorder.isTypeSupported(type));
69
+ if (supportedTypes.length === 0) {
70
+ throw new RecordingError("No supported audio formats found in this browser", {
71
+ context: { requestedTypes: supportedTypes },
72
+ hint: "Verify codec support or adjust the requested MIME types.",
73
+ displayMessage: "Microphone access is unavailable."
74
+ });
75
+ }
76
+ // Update config with best supported mime type
77
+ this.config.mimeType = supportedTypes[0];
78
+ await this.checkCurrentPermissionState();
79
+ this.onAudioData = onAudioData;
80
+ this.onAudioError = onAudioError ?? null;
81
+ this.suppressCallbacks = false; //reset the guard against late recorder events
82
+ // Get user media
83
+ this.mediaStream = await navigator.mediaDevices.getUserMedia({
84
+ audio: {
85
+ echoCancellation: false,
86
+ noiseSuppression: false,
87
+ autoGainControl: false,
88
+ sampleRate: 44100,
89
+ channelCount: 1,
90
+ },
91
+ });
92
+ // Create MediaRecorder with fallback if the selected format fails
93
+ this.mediaRecorder = new MediaRecorder(this.mediaStream, {
94
+ mimeType: this.config.mimeType,
95
+ audioBitsPerSecond: this.config.audioBitsPerSecond,
96
+ });
97
+ // Verify MediaRecorder is in a valid state
98
+ if (this.mediaRecorder.state !== "inactive") {
99
+ throw new RecordingError("MediaRecorder is in an invalid state for start()", {
100
+ context: { recorderState: this.mediaRecorder.state },
101
+ displayMessage: "Failed to start recording"
102
+ });
103
+ }
104
+ // Set up event handlers
105
+ this.setupMediaRecorderEvents();
106
+ // Start recording
107
+ this.mediaRecorder.start();
108
+ this.recordingStartTime = Date.now();
109
+ // Set up auto-pause timer if enabled
110
+ if (this.config.autoPauseEnabled) {
111
+ this.setupAutoPauseTimer();
112
+ }
113
+ }
114
+ catch (error) {
115
+ if (error instanceof Error && error.name === "NotAllowedError") {
116
+ throw new RecordingError("Microphone permission denied", {
117
+ context: { permissionState: "denied" },
118
+ hint: "Ensure the browser or device has microphone access enabled for this site/app.",
119
+ });
120
+ }
121
+ throw this.toRecordingError(error, "Failed to start audio recording", {
122
+ stage: "start",
123
+ });
124
+ }
125
+ }
126
+ async processAudioChunk(blob) {
127
+ try {
128
+ const base64Audio = await this.blobToBase64(blob);
129
+ const now = Date.now();
130
+ if (!this.onAudioData) {
131
+ return;
132
+ }
133
+ const audioData = {
134
+ audio: base64Audio,
135
+ format: this.config.mimeType,
136
+ duration: this.recordingStartTime > 0 ? now - this.recordingStartTime : 0,
137
+ timestamp: this.recordingStartTime > 0 ? this.recordingStartTime : now,
138
+ };
139
+ this.onAudioData(audioData);
140
+ }
141
+ catch (error) {
142
+ this.handleError(error, { stage: "process_audio_chunk" });
143
+ }
144
+ }
145
+ toRecordingError(error, fallbackMessage, context) {
146
+ if (error instanceof RecordingError) {
147
+ return error;
148
+ }
149
+ if (error instanceof SynapseError) {
150
+ return new RecordingError(error.message, {
151
+ cause: error,
152
+ context: { ...error.context, ...context },
153
+ hint: error.hint,
154
+ });
155
+ }
156
+ const message = error instanceof Error && error.message ? error.message : fallbackMessage;
157
+ return new RecordingError(message, {
158
+ cause: error,
159
+ context,
160
+ });
161
+ }
162
+ handleError(error, context) {
163
+ const recordingError = this.toRecordingError(error, "Unexpected recording error", context);
164
+ if (this.onAudioError) {
165
+ try {
166
+ this.onAudioError(recordingError);
167
+ }
168
+ catch (callbackError) {
169
+ console.error("Audio error callback failed:", callbackError);
170
+ }
171
+ }
172
+ else {
173
+ console.error("AudioManager error:", recordingError);
174
+ }
175
+ }
176
+ /**
177
+ * Set up auto-pause timer for 15-minute limit
178
+ */
179
+ setupAutoPauseTimer() {
180
+ if (this.autoPauseTimer) {
181
+ clearTimeout(this.autoPauseTimer);
182
+ }
183
+ this.autoPauseTimer = setTimeout(() => {
184
+ this.stop();
185
+ }, this.config.maxRecordingDuration);
186
+ }
187
+ setupMediaRecorderEvents() {
188
+ if (!this.mediaRecorder)
189
+ return;
190
+ this.mediaRecorder.ondataavailable = (event) => {
191
+ if (this.suppressCallbacks || event.data.size <= 0) {
192
+ return;
193
+ }
194
+ this.processAudioChunk(event.data).catch((error) => this.handleError(error, {
195
+ stage: "media_recorder_ondataavailable",
196
+ hasData: event.data.size > 0,
197
+ }));
198
+ };
199
+ this.mediaRecorder.onstop = async () => {
200
+ this.recordingStartTime = 0;
201
+ };
202
+ this.mediaRecorder.onerror = (event) => {
203
+ this.handleError(event.error instanceof Error
204
+ ? event.error
205
+ : new Error("MediaRecorder encountered an unknown error"), { stage: "media_recorder_onerror" });
206
+ };
207
+ this.mediaRecorder.onstart = () => {
208
+ };
209
+ }
210
+ /**
211
+ * Convert blob to base64 string
212
+ */
213
+ blobToBase64(blob) {
214
+ return new Promise((resolve, reject) => {
215
+ const reader = new FileReader();
216
+ reader.onload = () => {
217
+ if (typeof reader.result === "string") {
218
+ // Remove data URL prefix (e.g., "data:audio/mp3;base64,")
219
+ const base64 = reader.result.split(",")[1];
220
+ resolve(base64);
221
+ }
222
+ else {
223
+ reject(new RecordingError("Failed to convert blob to base64", {
224
+ context: { stage: "blob_to_base64" },
225
+ }));
226
+ }
227
+ };
228
+ reader.onerror = () => reject(new RecordingError("FileReader error while converting audio chunk to base64", {
229
+ cause: reader.error ?? undefined,
230
+ context: { stage: "blob_to_base64" },
231
+ }));
232
+ reader.readAsDataURL(blob);
233
+ });
234
+ }
235
+ /**
236
+ * Stop recording manually
237
+ */
238
+ stop() {
239
+ if (!this.mediaRecorder)
240
+ return;
241
+ try {
242
+ // Clear auto-pause timer
243
+ if (this.autoPauseTimer) {
244
+ clearTimeout(this.autoPauseTimer);
245
+ this.autoPauseTimer = null;
246
+ }
247
+ // Stop MediaRecorder
248
+ if (this.mediaRecorder.state === "recording") {
249
+ this.mediaRecorder.stop();
250
+ }
251
+ // Stop media stream
252
+ if (this.mediaStream) {
253
+ this.mediaStream.getTracks().forEach((track) => track.stop());
254
+ this.mediaStream = null;
255
+ }
256
+ }
257
+ catch (error) {
258
+ throw this.toRecordingError(error, "Failed to stop audio recording", {
259
+ stage: "stop",
260
+ });
261
+ }
262
+ }
263
+ /**
264
+ * Cancel recording (discard data and suppress callbacks)
265
+ */
266
+ cancel() {
267
+ if (!this.mediaRecorder)
268
+ return;
269
+ try {
270
+ this.suppressCallbacks = true;
271
+ if (this.autoPauseTimer) {
272
+ clearTimeout(this.autoPauseTimer);
273
+ this.autoPauseTimer = null;
274
+ }
275
+ // Prevent ondataavailable from pushing chunks
276
+ this.mediaRecorder.ondataavailable = null;
277
+ // Stop MediaRecorder
278
+ if (this.mediaRecorder.state === "recording") {
279
+ this.mediaRecorder.stop();
280
+ }
281
+ // Stop media stream
282
+ if (this.mediaStream) {
283
+ this.mediaStream.getTracks().forEach((track) => track.stop());
284
+ this.mediaStream = null;
285
+ }
286
+ }
287
+ catch (error) {
288
+ throw this.toRecordingError(error, "Failed to cancel audio recording", {
289
+ stage: "cancel",
290
+ });
291
+ }
292
+ }
293
+ destroy() {
294
+ try {
295
+ this.cancel();
296
+ if (this.mediaRecorder) {
297
+ this.mediaRecorder = null;
298
+ }
299
+ if (this.mediaStream) {
300
+ this.mediaStream.getTracks().forEach((track) => track.stop());
301
+ this.mediaStream = null;
302
+ }
303
+ }
304
+ catch (error) {
305
+ throw this.toRecordingError(error, "Failed to destroy audio resources", {
306
+ stage: "destroy",
307
+ });
308
+ }
309
+ }
310
+ }
@@ -0,0 +1,13 @@
1
+ export {};
2
+ // export const AudioRecordingStatus = {
3
+ // IDLE: "idle",
4
+ // LISTENING: "listening",
5
+ // ERROR: "error",
6
+ // PROCESSING: "processing",
7
+ // TRANSCRIBING: "transcribing",
8
+ // } as const;
9
+ // export type AudioRecordingStatus =
10
+ // (typeof AudioRecordingStatus)[keyof typeof AudioRecordingStatus];
11
+ // export type AudioRecordingStatusCallback = (
12
+ // status: AudioRecordingStatus
13
+ // ) => void;