@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.
- package/dist/Synapse.d.ts +0 -1
- package/dist/Synapse.js +2 -25
- package/dist/connection/ConnectionFactory.d.ts +0 -1
- package/dist/connection/SSE.d.ts +0 -1
- package/dist/connection/Websocket.d.ts +0 -1
- package/dist/constants/index.d.ts +0 -1
- package/dist/constants/types.d.ts +0 -1
- package/dist/conversation.d.ts +0 -1
- package/dist/esm/Synapse.js +612 -0
- package/dist/esm/connection/ConnectionFactory.js +27 -0
- package/dist/esm/connection/SSE.js +212 -0
- package/dist/esm/connection/Websocket.js +178 -0
- package/dist/esm/constants/index.js +25 -0
- package/dist/esm/constants/types.js +1 -0
- package/dist/esm/conversation.js +7 -0
- package/dist/esm/events/Events.js +41 -0
- package/dist/esm/events/Incoming.js +1 -0
- package/dist/esm/events/Outgoing.js +1 -0
- package/dist/esm/events/index.js +2 -0
- package/dist/esm/events/types.js +5 -0
- package/dist/esm/index.js +34 -0
- package/dist/esm/internal/Api/BaseResource.js +50 -0
- package/dist/esm/internal/Api/HttpClient.js +131 -0
- package/dist/esm/internal/Api/types.js +1 -0
- package/dist/esm/internal/Error/Error.js +229 -0
- package/dist/esm/internal/Error/types.js +9 -0
- package/dist/esm/internal/connection/BaseConnection.js +134 -0
- package/dist/esm/internal/connection/types.js +17 -0
- package/dist/esm/internal/events/EventEmitter.js +26 -0
- package/dist/esm/internal/store/index.js +5 -0
- package/dist/esm/media/audio/Audio.copy.js +363 -0
- package/dist/esm/media/audio/Audio.js +310 -0
- package/dist/esm/media/audio/types.js +13 -0
- package/dist/esm/media/file/File.js +159 -0
- package/dist/esm/messages/MessageManager.js +476 -0
- package/dist/esm/messages/types.js +35 -0
- package/dist/esm/resources/config/Config.js +11 -0
- package/dist/esm/resources/feedback/Feedback.js +9 -0
- package/dist/esm/resources/feedback/types.js +7 -0
- package/dist/esm/resources/index.js +152 -0
- package/dist/esm/resources/session/Session.js +44 -0
- package/dist/esm/resources/session/types.js +5 -0
- package/dist/esm/resources/toolCall/ToolCall.js +12 -0
- package/dist/esm/resources/toolCall/types.js +34 -0
- package/dist/esm/resources/types.js +4 -0
- package/dist/esm/resources/voice/VoiceResource.js +14 -0
- package/dist/esm/resources/voice/types.js +1 -0
- package/dist/esm/types/index.js +8 -0
- package/dist/esm/utils/Error.js +110 -0
- package/dist/esm/voice/VoiceAgent.js +305 -0
- package/dist/esm/voice/VoiceAudioAnalyser.js +32 -0
- package/dist/esm/voice/index.js +1 -0
- package/dist/esm/voice/types.js +15 -0
- package/dist/events/Events.d.ts +0 -1
- package/dist/events/Incoming.d.ts +0 -1
- package/dist/events/Outgoing.d.ts +0 -1
- package/dist/events/index.d.ts +0 -1
- package/dist/events/types.d.ts +0 -1
- package/dist/index.d.ts +0 -1
- package/dist/internal/Api/BaseResource.d.ts +0 -1
- package/dist/internal/Api/HttpClient.d.ts +0 -1
- package/dist/internal/Api/types.d.ts +0 -1
- package/dist/internal/Error/Error.d.ts +0 -1
- package/dist/internal/Error/types.d.ts +0 -1
- package/dist/internal/connection/BaseConnection.d.ts +0 -1
- package/dist/internal/connection/types.d.ts +0 -1
- package/dist/internal/events/EventEmitter.d.ts +0 -1
- package/dist/internal/store/index.d.ts +0 -1
- package/dist/media/audio/Audio.copy.d.ts +0 -1
- package/dist/media/audio/Audio.d.ts +0 -1
- package/dist/media/audio/types.d.ts +0 -1
- package/dist/media/file/File.d.ts +0 -1
- package/dist/messages/MessageManager.d.ts +4 -2
- package/dist/messages/MessageManager.js +29 -5
- package/dist/messages/types.d.ts +0 -1
- package/dist/resources/config/Config.d.ts +0 -1
- package/dist/resources/feedback/Feedback.d.ts +0 -1
- package/dist/resources/feedback/types.d.ts +0 -1
- package/dist/resources/index.d.ts +0 -1
- package/dist/resources/session/Session.d.ts +0 -1
- package/dist/resources/session/types.d.ts +0 -1
- package/dist/resources/toolCall/ToolCall.d.ts +0 -1
- package/dist/resources/toolCall/types.d.ts +0 -1
- package/dist/resources/types.d.ts +0 -1
- package/dist/resources/voice/VoiceResource.d.ts +0 -1
- package/dist/resources/voice/types.d.ts +0 -1
- package/dist/types/index.d.ts +0 -1
- package/dist/utils/Error.d.ts +0 -1
- package/dist/voice/VoiceAgent.d.ts +0 -1
- package/dist/voice/VoiceAudioAnalyser.d.ts +0 -1
- package/dist/voice/index.d.ts +0 -1
- package/dist/voice/types.d.ts +0 -1
- package/package.json +4 -2
- package/dist/Synapse.d.ts.map +0 -1
- package/dist/auth/constants.d.ts +0 -12
- package/dist/auth/constants.d.ts.map +0 -1
- package/dist/auth/constants.js +0 -10
- package/dist/auth/index.d.ts +0 -3
- package/dist/auth/index.d.ts.map +0 -1
- package/dist/auth/index.js +0 -18
- package/dist/auth/session.d.ts +0 -5
- package/dist/auth/session.d.ts.map +0 -1
- package/dist/auth/session.js +0 -36
- package/dist/connection/ConnectionFactory.d.ts.map +0 -1
- package/dist/connection/SSE.d.ts.map +0 -1
- package/dist/connection/Websocket.d.ts.map +0 -1
- package/dist/constants/index.d.ts.map +0 -1
- package/dist/constants/types.d.ts.map +0 -1
- package/dist/conversation.d.ts.map +0 -1
- package/dist/events/Events.d.ts.map +0 -1
- package/dist/events/Incoming.d.ts.map +0 -1
- package/dist/events/Outgoing.d.ts.map +0 -1
- package/dist/events/index.d.ts.map +0 -1
- package/dist/events/types.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/internal/Api/BaseResource.d.ts.map +0 -1
- package/dist/internal/Api/HttpClient.d.ts.map +0 -1
- package/dist/internal/Api/types.d.ts.map +0 -1
- package/dist/internal/Error/Error.d.ts.map +0 -1
- package/dist/internal/Error/types.d.ts.map +0 -1
- package/dist/internal/connection/BaseConnection.d.ts.map +0 -1
- package/dist/internal/connection/types.d.ts.map +0 -1
- package/dist/internal/events/EventEmitter.d.ts.map +0 -1
- package/dist/internal/store/index.d.ts.map +0 -1
- package/dist/media/audio/Audio.copy.d.ts.map +0 -1
- package/dist/media/audio/Audio.d.ts.map +0 -1
- package/dist/media/audio/types.d.ts.map +0 -1
- package/dist/media/file/File.d.ts.map +0 -1
- package/dist/messages/MessageManager.d.ts.map +0 -1
- package/dist/messages/types.d.ts.map +0 -1
- package/dist/resources/config/Config.d.ts.map +0 -1
- package/dist/resources/feedback/Feedback.d.ts.map +0 -1
- package/dist/resources/feedback/types.d.ts.map +0 -1
- package/dist/resources/index.d.ts.map +0 -1
- package/dist/resources/session/Session.d.ts.map +0 -1
- package/dist/resources/session/types.d.ts.map +0 -1
- package/dist/resources/toolCall/ToolCall.d.ts.map +0 -1
- package/dist/resources/toolCall/types.d.ts.map +0 -1
- package/dist/resources/types.d.ts.map +0 -1
- package/dist/resources/voice/VoiceResource.d.ts.map +0 -1
- package/dist/resources/voice/types.d.ts.map +0 -1
- package/dist/types/index.d.ts.map +0 -1
- package/dist/utils/Error.d.ts.map +0 -1
- package/dist/voice/VoiceAgent.d.ts.map +0 -1
- package/dist/voice/VoiceAudioAnalyser.d.ts.map +0 -1
- package/dist/voice/index.d.ts.map +0 -1
- 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;
|