@independo/capacitor-voice-recorder 8.0.2-dev.1 → 8.1.0-dev.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/README.md CHANGED
@@ -72,6 +72,9 @@ Below is an index of all available methods. Run `npm run docgen` after updating
72
72
  * [`pauseRecording()`](#pauserecording)
73
73
  * [`resumeRecording()`](#resumerecording)
74
74
  * [`getCurrentStatus()`](#getcurrentstatus)
75
+ * [`addListener('voiceRecordingInterrupted', ...)`](#addlistenervoicerecordinginterrupted-)
76
+ * [`addListener('voiceRecordingInterruptionEnded', ...)`](#addlistenervoicerecordinginterruptionended-)
77
+ * [`removeAllListeners()`](#removealllisteners)
75
78
  * [Interfaces](#interfaces)
76
79
  * [Type Aliases](#type-aliases)
77
80
  * [Enums](#enums)
@@ -162,6 +165,7 @@ Will stop the recording that has been previously started.
162
165
  If the function `startRecording` has not been called beforehand, the promise will reject with `RECORDING_HAS_NOT_STARTED`.
163
166
  If the recording has been stopped immediately after it has been started, the promise will reject with `EMPTY_RECORDING`.
164
167
  In a case of unknown error, the promise will reject with `FAILED_TO_FETCH_RECORDING`.
168
+ On iOS, if a recording interrupted by the system cannot be merged, the promise will reject with `FAILED_TO_MERGE_RECORDING`.
165
169
  In case of success, the promise resolves to <a href="#recordingdata">RecordingData</a> containing the recording in base-64, the duration of the recording in milliseconds, and the MIME type.
166
170
 
167
171
  **Returns:** <code>Promise&lt;<a href="#recordingdata">RecordingData</a>&gt;</code>
@@ -191,7 +195,7 @@ On certain mobile OS versions, this function is not supported and will reject wi
191
195
  resumeRecording() => Promise<GenericResponse>
192
196
  ```
193
197
 
194
- Resumes a paused audio recording.
198
+ Resumes a paused or interrupted audio recording.
195
199
  If the recording has not started yet, the promise will reject with an error code `RECORDING_HAS_NOT_STARTED`.
196
200
  On success, the promise will resolve to { value: true } if the resume was successful or { value: false } if the recording is already running.
197
201
  On certain mobile OS versions, this function is not supported and will reject with `NOT_SUPPORTED_OS_VERSION`.
@@ -212,12 +216,62 @@ Will resolve with one of the following values:
212
216
  `{ status: "NONE" }` if the plugin is idle and waiting to start a new recording.
213
217
  `{ status: "RECORDING" }` if the plugin is in the middle of recording.
214
218
  `{ status: "PAUSED" }` if the recording is paused.
219
+ `{ status: "INTERRUPTED" }` if the recording was paused due to a system interruption.
215
220
 
216
221
  **Returns:** <code>Promise&lt;<a href="#currentrecordingstatus">CurrentRecordingStatus</a>&gt;</code>
217
222
 
218
223
  --------------------
219
224
 
220
225
 
226
+ ### addListener('voiceRecordingInterrupted', ...)
227
+
228
+ ```typescript
229
+ addListener(eventName: 'voiceRecordingInterrupted', listenerFunc: (event: VoiceRecordingInterruptedEvent) => void) => Promise<PluginListenerHandle>
230
+ ```
231
+
232
+ Listen for audio recording interruptions (e.g., phone calls, other apps using microphone).
233
+ Available on iOS and Android only.
234
+
235
+ | Param | Type | Description |
236
+ | ------------------ | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
237
+ | **`eventName`** | <code>'voiceRecordingInterrupted'</code> | The name of the event to listen for. |
238
+ | **`listenerFunc`** | <code>(event: <a href="#voicerecordinginterruptedevent">VoiceRecordingInterruptedEvent</a>) =&gt; void</code> | The callback function to invoke when the event occurs. |
239
+
240
+ **Returns:** <code>Promise&lt;<a href="#pluginlistenerhandle">PluginListenerHandle</a>&gt;</code>
241
+
242
+ --------------------
243
+
244
+
245
+ ### addListener('voiceRecordingInterruptionEnded', ...)
246
+
247
+ ```typescript
248
+ addListener(eventName: 'voiceRecordingInterruptionEnded', listenerFunc: (event: VoiceRecordingInterruptionEndedEvent) => void) => Promise<PluginListenerHandle>
249
+ ```
250
+
251
+ Listen for audio recording interruption end events.
252
+ Available on iOS and Android only.
253
+
254
+ | Param | Type | Description |
255
+ | ------------------ | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
256
+ | **`eventName`** | <code>'voiceRecordingInterruptionEnded'</code> | The name of the event to listen for. |
257
+ | **`listenerFunc`** | <code>(event: <a href="#voicerecordinginterruptionendedevent">VoiceRecordingInterruptionEndedEvent</a>) =&gt; void</code> | The callback function to invoke when the event occurs. |
258
+
259
+ **Returns:** <code>Promise&lt;<a href="#pluginlistenerhandle">PluginListenerHandle</a>&gt;</code>
260
+
261
+ --------------------
262
+
263
+
264
+ ### removeAllListeners()
265
+
266
+ ```typescript
267
+ removeAllListeners() => Promise<void>
268
+ ```
269
+
270
+ Remove all listeners for this plugin.
271
+
272
+ --------------------
273
+
274
+
221
275
  ### Interfaces
222
276
 
223
277
 
@@ -253,9 +307,16 @@ Interface representing the data of a recording.
253
307
 
254
308
  Interface representing the current status of the voice recorder.
255
309
 
256
- | Prop | Type | Description |
257
- | ------------ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
258
- | **`status`** | <code>'NONE' \| 'RECORDING' \| 'PAUSED'</code> | The current status of the recorder, which can be one of the following values: 'RECORDING', 'PAUSED', 'NONE'. |
310
+ | Prop | Type | Description |
311
+ | ------------ | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
312
+ | **`status`** | <code>'NONE' \| 'RECORDING' \| 'PAUSED' \| 'INTERRUPTED'</code> | The current status of the recorder, which can be one of the following values: 'RECORDING', 'PAUSED', 'INTERRUPTED', 'NONE'. |
313
+
314
+
315
+ #### PluginListenerHandle
316
+
317
+ | Prop | Type |
318
+ | ------------ | ----------------------------------------- |
319
+ | **`remove`** | <code>() =&gt; Promise&lt;void&gt;</code> |
259
320
 
260
321
 
261
322
  ### Type Aliases
@@ -268,6 +329,27 @@ Represents a Base64 encoded string.
268
329
  <code>string</code>
269
330
 
270
331
 
332
+ #### VoiceRecordingInterruptedEvent
333
+
334
+ Event payload for voiceRecordingInterrupted event (empty - no data).
335
+
336
+ <code><a href="#record">Record</a>&lt;string, never&gt;</code>
337
+
338
+
339
+ #### Record
340
+
341
+ Construct a type with a set of properties K of type T
342
+
343
+ <code>{
271
344
  [P in K]: T;
272
345
  }</code>
346
+
347
+
348
+ #### VoiceRecordingInterruptionEndedEvent
349
+
350
+ Event payload for voiceRecordingInterruptionEnded event (empty - no data).
351
+
352
+ <code><a href="#record">Record</a>&lt;string, never&gt;</code>
353
+
354
+
273
355
  ### Enums
274
356
 
275
357
 
@@ -287,6 +369,12 @@ Represents a Base64 encoded string.
287
369
 
288
370
  </docgen-api>
289
371
 
372
+ ## Audio interruption handling
373
+
374
+ On iOS and Android, the plugin listens for system audio interruptions (phone calls, other apps taking audio focus). When an interruption begins, the recording is paused, the status becomes `INTERRUPTED`, and the `voiceRecordingInterrupted` event fires. When the interruption ends, the `voiceRecordingInterruptionEnded` event fires, and the status stays `INTERRUPTED` until you call `resumeRecording()` or `stopRecording()`. Web does not provide interruption handling.
375
+
376
+ If interruptions occur on iOS, recordings are segmented and merged when you stop. The merged file is M4A with MIME type `audio/mp4`. Recordings without interruptions remain AAC with MIME type `audio/aac`.
377
+
290
378
 
291
379
  ## Format and Mime type
292
380
 
@@ -3,5 +3,6 @@ package com.tchvu3.capacitorvoicerecorder;
3
3
  public enum CurrentRecordingStatus {
4
4
  RECORDING,
5
5
  PAUSED,
6
+ INTERRUPTED,
6
7
  NONE
7
8
  }
@@ -1,6 +1,9 @@
1
1
  package com.tchvu3.capacitorvoicerecorder;
2
2
 
3
3
  import android.content.Context;
4
+ import android.media.AudioAttributes;
5
+ import android.media.AudioFocusRequest;
6
+ import android.media.AudioManager;
4
7
  import android.media.MediaRecorder;
5
8
  import android.os.Build;
6
9
  import android.os.Environment;
@@ -9,20 +12,33 @@ import java.io.IOException;
9
12
  import java.util.regex.Matcher;
10
13
  import java.util.regex.Pattern;
11
14
 
12
- public class CustomMediaRecorder {
15
+ public class CustomMediaRecorder implements AudioManager.OnAudioFocusChangeListener {
13
16
 
14
17
  private final Context context;
15
18
  private final RecordOptions options;
16
19
  private MediaRecorder mediaRecorder;
17
20
  private File outputFile;
18
21
  private CurrentRecordingStatus currentRecordingStatus = CurrentRecordingStatus.NONE;
22
+ private AudioManager audioManager;
23
+ private AudioFocusRequest audioFocusRequest;
24
+ private Runnable onInterruptionBegan;
25
+ private Runnable onInterruptionEnded;
19
26
 
20
27
  public CustomMediaRecorder(Context context, RecordOptions options) throws IOException {
21
28
  this.context = context;
22
29
  this.options = options;
30
+ this.audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
23
31
  generateMediaRecorder();
24
32
  }
25
33
 
34
+ public void setOnInterruptionBegan(Runnable callback) {
35
+ this.onInterruptionBegan = callback;
36
+ }
37
+
38
+ public void setOnInterruptionEnded(Runnable callback) {
39
+ this.onInterruptionEnded = callback;
40
+ }
41
+
26
42
  private void generateMediaRecorder() throws IOException {
27
43
  mediaRecorder = new MediaRecorder();
28
44
  mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);
@@ -75,14 +91,31 @@ public class CustomMediaRecorder {
75
91
  }
76
92
 
77
93
  public void startRecording() {
94
+ requestAudioFocus();
78
95
  mediaRecorder.start();
79
96
  currentRecordingStatus = CurrentRecordingStatus.RECORDING;
80
97
  }
81
98
 
82
99
  public void stopRecording() {
83
- mediaRecorder.stop();
84
- mediaRecorder.release();
85
- currentRecordingStatus = CurrentRecordingStatus.NONE;
100
+ if (mediaRecorder == null) {
101
+ abandonAudioFocus();
102
+ currentRecordingStatus = CurrentRecordingStatus.NONE;
103
+ return;
104
+ }
105
+
106
+ try {
107
+ if (currentRecordingStatus == CurrentRecordingStatus.RECORDING
108
+ || currentRecordingStatus == CurrentRecordingStatus.PAUSED
109
+ || currentRecordingStatus == CurrentRecordingStatus.INTERRUPTED) {
110
+ mediaRecorder.stop();
111
+ }
112
+ } catch (IllegalStateException ignore) {
113
+ } finally {
114
+ mediaRecorder.release();
115
+ mediaRecorder = null;
116
+ abandonAudioFocus();
117
+ currentRecordingStatus = CurrentRecordingStatus.NONE;
118
+ }
86
119
  }
87
120
 
88
121
  public File getOutputFile() {
@@ -112,7 +145,8 @@ public class CustomMediaRecorder {
112
145
  throw new NotSupportedOsVersion();
113
146
  }
114
147
 
115
- if (currentRecordingStatus == CurrentRecordingStatus.PAUSED) {
148
+ if (currentRecordingStatus == CurrentRecordingStatus.PAUSED || currentRecordingStatus == CurrentRecordingStatus.INTERRUPTED) {
149
+ requestAudioFocus();
116
150
  mediaRecorder.resume();
117
151
  currentRecordingStatus = CurrentRecordingStatus.RECORDING;
118
152
  return true;
@@ -146,4 +180,71 @@ public class CustomMediaRecorder {
146
180
  if (tempMediaRecorder != null) tempMediaRecorder.deleteOutputFile();
147
181
  }
148
182
  }
183
+
184
+ private void requestAudioFocus() {
185
+ if (audioManager == null) {
186
+ return;
187
+ }
188
+
189
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
190
+ AudioAttributes audioAttributes = new AudioAttributes.Builder()
191
+ .setUsage(AudioAttributes.USAGE_MEDIA)
192
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
193
+ .build();
194
+
195
+ audioFocusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
196
+ .setAudioAttributes(audioAttributes)
197
+ .setOnAudioFocusChangeListener(this)
198
+ .build();
199
+
200
+ audioManager.requestAudioFocus(audioFocusRequest);
201
+ } else {
202
+ audioManager.requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN);
203
+ }
204
+ }
205
+
206
+ private void abandonAudioFocus() {
207
+ if (audioManager == null) {
208
+ return;
209
+ }
210
+
211
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && audioFocusRequest != null) {
212
+ audioManager.abandonAudioFocusRequest(audioFocusRequest);
213
+ audioFocusRequest = null;
214
+ } else {
215
+ audioManager.abandonAudioFocus(this);
216
+ }
217
+ }
218
+
219
+ @Override
220
+ public void onAudioFocusChange(int focusChange) {
221
+ switch (focusChange) {
222
+ case AudioManager.AUDIOFOCUS_LOSS:
223
+ case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
224
+ case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
225
+ // For voice recording, ducking still degrades captured audio, so treat all loss types as interruptions.
226
+ if (currentRecordingStatus == CurrentRecordingStatus.RECORDING) {
227
+ try {
228
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
229
+ mediaRecorder.pause();
230
+ currentRecordingStatus = CurrentRecordingStatus.INTERRUPTED;
231
+ if (onInterruptionBegan != null) {
232
+ onInterruptionBegan.run();
233
+ }
234
+ }
235
+ } catch (Exception ignore) {
236
+ }
237
+ }
238
+ break;
239
+ case AudioManager.AUDIOFOCUS_GAIN:
240
+ if (currentRecordingStatus == CurrentRecordingStatus.INTERRUPTED) {
241
+ if (onInterruptionEnded != null) {
242
+ onInterruptionEnded.run();
243
+ }
244
+ }
245
+ break;
246
+ default:
247
+ break;
248
+ }
249
+ }
149
250
  }
@@ -82,6 +82,8 @@ public class VoiceRecorder extends Plugin {
82
82
  String subDirectory = call.getString("subDirectory");
83
83
  RecordOptions options = new RecordOptions(directory, subDirectory);
84
84
  mediaRecorder = new CustomMediaRecorder(getContext(), options);
85
+ mediaRecorder.setOnInterruptionBegan(() -> notifyListeners("voiceRecordingInterrupted", null));
86
+ mediaRecorder.setOnInterruptionEnded(() -> notifyListeners("voiceRecordingInterruptionEnded", null));
85
87
  mediaRecorder.startRecording();
86
88
  call.resolve(ResponseGenerator.successResponse());
87
89
  } catch (Exception exp) {
package/dist/docs.json CHANGED
@@ -115,7 +115,7 @@
115
115
  "text": "Error with one of the specified error codes if the recording cannot be stopped."
116
116
  }
117
117
  ],
118
- "docs": "Stops audio recording.\nWill stop the recording that has been previously started.\nIf the function `startRecording` has not been called beforehand, the promise will reject with `RECORDING_HAS_NOT_STARTED`.\nIf the recording has been stopped immediately after it has been started, the promise will reject with `EMPTY_RECORDING`.\nIn a case of unknown error, the promise will reject with `FAILED_TO_FETCH_RECORDING`.\nIn case of success, the promise resolves to RecordingData containing the recording in base-64, the duration of the recording in milliseconds, and the MIME type.",
118
+ "docs": "Stops audio recording.\nWill stop the recording that has been previously started.\nIf the function `startRecording` has not been called beforehand, the promise will reject with `RECORDING_HAS_NOT_STARTED`.\nIf the recording has been stopped immediately after it has been started, the promise will reject with `EMPTY_RECORDING`.\nIn a case of unknown error, the promise will reject with `FAILED_TO_FETCH_RECORDING`.\nOn iOS, if a recording interrupted by the system cannot be merged, the promise will reject with `FAILED_TO_MERGE_RECORDING`.\nIn case of success, the promise resolves to RecordingData containing the recording in base-64, the duration of the recording in milliseconds, and the MIME type.",
119
119
  "complexTypes": [
120
120
  "RecordingData"
121
121
  ],
@@ -157,7 +157,7 @@
157
157
  "text": "Error with one of the specified error codes if the recording cannot be resumed."
158
158
  }
159
159
  ],
160
- "docs": "Resumes a paused audio recording.\nIf the recording has not started yet, the promise will reject with an error code `RECORDING_HAS_NOT_STARTED`.\nOn success, the promise will resolve to { value: true } if the resume was successful or { value: false } if the recording is already running.\nOn certain mobile OS versions, this function is not supported and will reject with `NOT_SUPPORTED_OS_VERSION`.",
160
+ "docs": "Resumes a paused or interrupted audio recording.\nIf the recording has not started yet, the promise will reject with an error code `RECORDING_HAS_NOT_STARTED`.\nOn success, the promise will resolve to { value: true } if the resume was successful or { value: false } if the recording is already running.\nOn certain mobile OS versions, this function is not supported and will reject with `NOT_SUPPORTED_OS_VERSION`.",
161
161
  "complexTypes": [
162
162
  "GenericResponse"
163
163
  ],
@@ -178,11 +178,95 @@
178
178
  "text": "Error if the status cannot be fetched."
179
179
  }
180
180
  ],
181
- "docs": "Gets the current status of the voice recorder.\nWill resolve with one of the following values:\n`{ status: \"NONE\" }` if the plugin is idle and waiting to start a new recording.\n`{ status: \"RECORDING\" }` if the plugin is in the middle of recording.\n`{ status: \"PAUSED\" }` if the recording is paused.",
181
+ "docs": "Gets the current status of the voice recorder.\nWill resolve with one of the following values:\n`{ status: \"NONE\" }` if the plugin is idle and waiting to start a new recording.\n`{ status: \"RECORDING\" }` if the plugin is in the middle of recording.\n`{ status: \"PAUSED\" }` if the recording is paused.\n`{ status: \"INTERRUPTED\" }` if the recording was paused due to a system interruption.",
182
182
  "complexTypes": [
183
183
  "CurrentRecordingStatus"
184
184
  ],
185
185
  "slug": "getcurrentstatus"
186
+ },
187
+ {
188
+ "name": "addListener",
189
+ "signature": "(eventName: 'voiceRecordingInterrupted', listenerFunc: (event: VoiceRecordingInterruptedEvent) => void) => Promise<PluginListenerHandle>",
190
+ "parameters": [
191
+ {
192
+ "name": "eventName",
193
+ "docs": "The name of the event to listen for.",
194
+ "type": "'voiceRecordingInterrupted'"
195
+ },
196
+ {
197
+ "name": "listenerFunc",
198
+ "docs": "The callback function to invoke when the event occurs.",
199
+ "type": "(event: VoiceRecordingInterruptedEvent) => void"
200
+ }
201
+ ],
202
+ "returns": "Promise<PluginListenerHandle>",
203
+ "tags": [
204
+ {
205
+ "name": "param",
206
+ "text": "eventName The name of the event to listen for."
207
+ },
208
+ {
209
+ "name": "param",
210
+ "text": "listenerFunc The callback function to invoke when the event occurs."
211
+ },
212
+ {
213
+ "name": "returns",
214
+ "text": "A promise that resolves to a PluginListenerHandle."
215
+ }
216
+ ],
217
+ "docs": "Listen for audio recording interruptions (e.g., phone calls, other apps using microphone).\nAvailable on iOS and Android only.",
218
+ "complexTypes": [
219
+ "PluginListenerHandle",
220
+ "VoiceRecordingInterruptedEvent"
221
+ ],
222
+ "slug": "addlistenervoicerecordinginterrupted-"
223
+ },
224
+ {
225
+ "name": "addListener",
226
+ "signature": "(eventName: 'voiceRecordingInterruptionEnded', listenerFunc: (event: VoiceRecordingInterruptionEndedEvent) => void) => Promise<PluginListenerHandle>",
227
+ "parameters": [
228
+ {
229
+ "name": "eventName",
230
+ "docs": "The name of the event to listen for.",
231
+ "type": "'voiceRecordingInterruptionEnded'"
232
+ },
233
+ {
234
+ "name": "listenerFunc",
235
+ "docs": "The callback function to invoke when the event occurs.",
236
+ "type": "(event: VoiceRecordingInterruptionEndedEvent) => void"
237
+ }
238
+ ],
239
+ "returns": "Promise<PluginListenerHandle>",
240
+ "tags": [
241
+ {
242
+ "name": "param",
243
+ "text": "eventName The name of the event to listen for."
244
+ },
245
+ {
246
+ "name": "param",
247
+ "text": "listenerFunc The callback function to invoke when the event occurs."
248
+ },
249
+ {
250
+ "name": "returns",
251
+ "text": "A promise that resolves to a PluginListenerHandle."
252
+ }
253
+ ],
254
+ "docs": "Listen for audio recording interruption end events.\nAvailable on iOS and Android only.",
255
+ "complexTypes": [
256
+ "PluginListenerHandle",
257
+ "VoiceRecordingInterruptionEndedEvent"
258
+ ],
259
+ "slug": "addlistenervoicerecordinginterruptionended-"
260
+ },
261
+ {
262
+ "name": "removeAllListeners",
263
+ "signature": "() => Promise<void>",
264
+ "parameters": [],
265
+ "returns": "Promise<void>",
266
+ "tags": [],
267
+ "docs": "Remove all listeners for this plugin.",
268
+ "complexTypes": [],
269
+ "slug": "removealllisteners"
186
270
  }
187
271
  ],
188
272
  "properties": []
@@ -262,9 +346,25 @@
262
346
  {
263
347
  "name": "status",
264
348
  "tags": [],
265
- "docs": "The current status of the recorder, which can be one of the following values: 'RECORDING', 'PAUSED', 'NONE'.",
349
+ "docs": "The current status of the recorder, which can be one of the following values: 'RECORDING', 'PAUSED', 'INTERRUPTED', 'NONE'.",
266
350
  "complexTypes": [],
267
- "type": "'NONE' | 'RECORDING' | 'PAUSED'"
351
+ "type": "'NONE' | 'RECORDING' | 'PAUSED' | 'INTERRUPTED'"
352
+ }
353
+ ]
354
+ },
355
+ {
356
+ "name": "PluginListenerHandle",
357
+ "slug": "pluginlistenerhandle",
358
+ "docs": "",
359
+ "tags": [],
360
+ "methods": [],
361
+ "properties": [
362
+ {
363
+ "name": "remove",
364
+ "tags": [],
365
+ "docs": "",
366
+ "complexTypes": [],
367
+ "type": "() => Promise<void>"
268
368
  }
269
369
  ]
270
370
  }
@@ -387,6 +487,46 @@
387
487
  "complexTypes": []
388
488
  }
389
489
  ]
490
+ },
491
+ {
492
+ "name": "VoiceRecordingInterruptedEvent",
493
+ "slug": "voicerecordinginterruptedevent",
494
+ "docs": "Event payload for voiceRecordingInterrupted event (empty - no data).",
495
+ "types": [
496
+ {
497
+ "text": "Record<string, never>",
498
+ "complexTypes": [
499
+ "Record"
500
+ ]
501
+ }
502
+ ]
503
+ },
504
+ {
505
+ "name": "Record",
506
+ "slug": "record",
507
+ "docs": "Construct a type with a set of properties K of type T",
508
+ "types": [
509
+ {
510
+ "text": "{\r\n [P in K]: T;\r\n}",
511
+ "complexTypes": [
512
+ "K",
513
+ "T"
514
+ ]
515
+ }
516
+ ]
517
+ },
518
+ {
519
+ "name": "VoiceRecordingInterruptionEndedEvent",
520
+ "slug": "voicerecordinginterruptionendedevent",
521
+ "docs": "Event payload for voiceRecordingInterruptionEnded event (empty - no data).",
522
+ "types": [
523
+ {
524
+ "text": "Record<string, never>",
525
+ "complexTypes": [
526
+ "Record"
527
+ ]
528
+ }
529
+ ]
390
530
  }
391
531
  ],
392
532
  "pluginConfigs": []
@@ -1,3 +1,4 @@
1
+ import type { PluginListenerHandle } from "@capacitor/core";
1
2
  import type { Directory } from "@capacitor/filesystem";
2
3
  /**
3
4
  * Represents a Base64 encoded string.
@@ -59,10 +60,18 @@ export interface GenericResponse {
59
60
  */
60
61
  export interface CurrentRecordingStatus {
61
62
  /**
62
- * The current status of the recorder, which can be one of the following values: 'RECORDING', 'PAUSED', 'NONE'.
63
+ * The current status of the recorder, which can be one of the following values: 'RECORDING', 'PAUSED', 'INTERRUPTED', 'NONE'.
63
64
  */
64
- status: 'RECORDING' | 'PAUSED' | 'NONE';
65
+ status: 'RECORDING' | 'PAUSED' | 'INTERRUPTED' | 'NONE';
65
66
  }
67
+ /**
68
+ * Event payload for voiceRecordingInterrupted event (empty - no data).
69
+ */
70
+ export type VoiceRecordingInterruptedEvent = Record<string, never>;
71
+ /**
72
+ * Event payload for voiceRecordingInterruptionEnded event (empty - no data).
73
+ */
74
+ export type VoiceRecordingInterruptionEndedEvent = Record<string, never>;
66
75
  /**
67
76
  * Interface for the VoiceRecorderPlugin which provides methods to record audio.
68
77
  */
@@ -110,6 +119,7 @@ export interface VoiceRecorderPlugin {
110
119
  * If the function `startRecording` has not been called beforehand, the promise will reject with `RECORDING_HAS_NOT_STARTED`.
111
120
  * If the recording has been stopped immediately after it has been started, the promise will reject with `EMPTY_RECORDING`.
112
121
  * In a case of unknown error, the promise will reject with `FAILED_TO_FETCH_RECORDING`.
122
+ * On iOS, if a recording interrupted by the system cannot be merged, the promise will reject with `FAILED_TO_MERGE_RECORDING`.
113
123
  * In case of success, the promise resolves to RecordingData containing the recording in base-64, the duration of the recording in milliseconds, and the MIME type.
114
124
  * @returns A promise that resolves to RecordingData.
115
125
  * @throws Error with one of the specified error codes if the recording cannot be stopped.
@@ -125,7 +135,7 @@ export interface VoiceRecorderPlugin {
125
135
  */
126
136
  pauseRecording(): Promise<GenericResponse>;
127
137
  /**
128
- * Resumes a paused audio recording.
138
+ * Resumes a paused or interrupted audio recording.
129
139
  * If the recording has not started yet, the promise will reject with an error code `RECORDING_HAS_NOT_STARTED`.
130
140
  * On success, the promise will resolve to { value: true } if the resume was successful or { value: false } if the recording is already running.
131
141
  * On certain mobile OS versions, this function is not supported and will reject with `NOT_SUPPORTED_OS_VERSION`.
@@ -139,8 +149,31 @@ export interface VoiceRecorderPlugin {
139
149
  * `{ status: "NONE" }` if the plugin is idle and waiting to start a new recording.
140
150
  * `{ status: "RECORDING" }` if the plugin is in the middle of recording.
141
151
  * `{ status: "PAUSED" }` if the recording is paused.
152
+ * `{ status: "INTERRUPTED" }` if the recording was paused due to a system interruption.
142
153
  * @returns A promise that resolves to a CurrentRecordingStatus.
143
154
  * @throws Error if the status cannot be fetched.
144
155
  */
145
156
  getCurrentStatus(): Promise<CurrentRecordingStatus>;
157
+ /**
158
+ * Listen for audio recording interruptions (e.g., phone calls, other apps using microphone).
159
+ * Available on iOS and Android only.
160
+ *
161
+ * @param eventName The name of the event to listen for.
162
+ * @param listenerFunc The callback function to invoke when the event occurs.
163
+ * @returns A promise that resolves to a PluginListenerHandle.
164
+ */
165
+ addListener(eventName: 'voiceRecordingInterrupted', listenerFunc: (event: VoiceRecordingInterruptedEvent) => void): Promise<PluginListenerHandle>;
166
+ /**
167
+ * Listen for audio recording interruption end events.
168
+ * Available on iOS and Android only.
169
+ *
170
+ * @param eventName The name of the event to listen for.
171
+ * @param listenerFunc The callback function to invoke when the event occurs.
172
+ * @returns A promise that resolves to a PluginListenerHandle.
173
+ */
174
+ addListener(eventName: 'voiceRecordingInterruptionEnded', listenerFunc: (event: VoiceRecordingInterruptionEndedEvent) => void): Promise<PluginListenerHandle>;
175
+ /**
176
+ * Remove all listeners for this plugin.
177
+ */
178
+ removeAllListeners(): Promise<void>;
146
179
  }
@@ -1 +1 @@
1
- {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["import type {Directory} from \"@capacitor/filesystem\";\n\n/**\n * Represents a Base64 encoded string.\n */\nexport type Base64String = string;\n\n/**\n * Can be used to specify options for the recording.\n */\nexport interface RecordingOptions {\n /**\n * The capacitor filesystem directory where the recording should be saved.\n *\n * If not specified, the recording will be stored in a base64 string and returned in the `RecordingData` object.\n * @see RecordingData\n */\n directory?: Directory;\n\n /**\n * An optional subdirectory in the specified directory where the recording should be saved.\n */\n subDirectory?: string;\n}\n\n/**\n * Interface representing the data of a recording.\n */\nexport interface RecordingData {\n /**\n * The value containing the recording details.\n */\n value: {\n /**\n * The recorded data as a Base64 encoded string.\n */\n recordDataBase64: Base64String;\n /**\n * The duration of the recording in milliseconds.\n */\n msDuration: number;\n /**\n * The MIME type of the recorded file.\n */\n mimeType: string;\n\n /**\n * The URI of the recording file.\n */\n uri?: string;\n };\n}\n\n/**\n * Interface representing a generic response with a boolean value.\n */\nexport interface GenericResponse {\n /**\n * The result of the operation as a boolean value.\n */\n value: boolean;\n}\n\n/**\n * Interface representing the current status of the voice recorder.\n */\nexport interface CurrentRecordingStatus {\n /**\n * The current status of the recorder, which can be one of the following values: 'RECORDING', 'PAUSED', 'NONE'.\n */\n status: 'RECORDING' | 'PAUSED' | 'NONE';\n}\n\n/**\n * Interface for the VoiceRecorderPlugin which provides methods to record audio.\n */\nexport interface VoiceRecorderPlugin {\n /**\n * Checks if the current device can record audio.\n * On mobile, this function will always resolve to `{ value: true }`.\n * In a browser, it will resolve to `{ value: true }` or `{ value: false }` based on the browser's ability to record.\n * This method does not take into account the permission status, only if the browser itself is capable of recording at all.\n * @returns A promise that resolves to a GenericResponse.\n * @throws Error with code \"COULD_NOT_QUERY_PERMISSION_STATUS\" if the device cannot query the permission status.\n */\n canDeviceVoiceRecord(): Promise<GenericResponse>;\n\n /**\n * Requests audio recording permission from the user.\n * If the permission has already been provided, the promise will resolve with `{ value: true }`.\n * Otherwise, the promise will resolve to `{ value: true }` or `{ value: false }` based on the user's response.\n * @returns A promise that resolves to a GenericResponse.\n * @throws Error if the permission request fails.\n */\n requestAudioRecordingPermission(): Promise<GenericResponse>;\n\n /**\n * Checks if audio recording permission has been granted.\n * Will resolve to `{ value: true }` or `{ value: false }` based on the status of the permission.\n * The web implementation of this plugin uses the Permissions API, which is not widespread.\n * If the status of the permission cannot be checked, the promise will reject with `COULD_NOT_QUERY_PERMISSION_STATUS`.\n * In that case, use `requestAudioRecordingPermission` or `startRecording` and capture any exception that is thrown.\n * @returns A promise that resolves to a GenericResponse.\n * @throws Error with code \"COULD_NOT_QUERY_PERMISSION_STATUS\" if the device cannot query the permission status.\n */\n hasAudioRecordingPermission(): Promise<GenericResponse>;\n\n /**\n * Starts audio recording.\n * On success, the promise will resolve to { value: true }.\n * On error, the promise will reject with one of the following error codes:\n * \"MISSING_PERMISSION\", \"ALREADY_RECORDING\", \"MICROPHONE_BEING_USED\", \"DEVICE_CANNOT_VOICE_RECORD\", or \"FAILED_TO_RECORD\".\n * @param options The options for the recording.\n * @returns A promise that resolves to a GenericResponse.\n * @throws Error with one of the specified error codes if the recording cannot be started.\n */\n startRecording(options?: RecordingOptions): Promise<GenericResponse>;\n\n /**\n * Stops audio recording.\n * Will stop the recording that has been previously started.\n * If the function `startRecording` has not been called beforehand, the promise will reject with `RECORDING_HAS_NOT_STARTED`.\n * If the recording has been stopped immediately after it has been started, the promise will reject with `EMPTY_RECORDING`.\n * In a case of unknown error, the promise will reject with `FAILED_TO_FETCH_RECORDING`.\n * In case of success, the promise resolves to RecordingData containing the recording in base-64, the duration of the recording in milliseconds, and the MIME type.\n * @returns A promise that resolves to RecordingData.\n * @throws Error with one of the specified error codes if the recording cannot be stopped.\n */\n stopRecording(): Promise<RecordingData>;\n\n /**\n * Pauses the ongoing audio recording.\n * If the recording has not started yet, the promise will reject with an error code `RECORDING_HAS_NOT_STARTED`.\n * On success, the promise will resolve to { value: true } if the pause was successful or { value: false } if the recording is already paused.\n * On certain mobile OS versions, this function is not supported and will reject with `NOT_SUPPORTED_OS_VERSION`.\n * @returns A promise that resolves to a GenericResponse.\n * @throws Error with one of the specified error codes if the recording cannot be paused.\n */\n pauseRecording(): Promise<GenericResponse>;\n\n /**\n * Resumes a paused audio recording.\n * If the recording has not started yet, the promise will reject with an error code `RECORDING_HAS_NOT_STARTED`.\n * On success, the promise will resolve to { value: true } if the resume was successful or { value: false } if the recording is already running.\n * On certain mobile OS versions, this function is not supported and will reject with `NOT_SUPPORTED_OS_VERSION`.\n * @returns A promise that resolves to a GenericResponse.\n * @throws Error with one of the specified error codes if the recording cannot be resumed.\n */\n resumeRecording(): Promise<GenericResponse>;\n\n /**\n * Gets the current status of the voice recorder.\n * Will resolve with one of the following values:\n * `{ status: \"NONE\" }` if the plugin is idle and waiting to start a new recording.\n * `{ status: \"RECORDING\" }` if the plugin is in the middle of recording.\n * `{ status: \"PAUSED\" }` if the recording is paused.\n * @returns A promise that resolves to a CurrentRecordingStatus.\n * @throws Error if the status cannot be fetched.\n */\n getCurrentStatus(): Promise<CurrentRecordingStatus>;\n}\n"]}
1
+ {"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["import type {PluginListenerHandle} from \"@capacitor/core\";\nimport type {Directory} from \"@capacitor/filesystem\";\n\n/**\n * Represents a Base64 encoded string.\n */\nexport type Base64String = string;\n\n/**\n * Can be used to specify options for the recording.\n */\nexport interface RecordingOptions {\n /**\n * The capacitor filesystem directory where the recording should be saved.\n *\n * If not specified, the recording will be stored in a base64 string and returned in the `RecordingData` object.\n * @see RecordingData\n */\n directory?: Directory;\n\n /**\n * An optional subdirectory in the specified directory where the recording should be saved.\n */\n subDirectory?: string;\n}\n\n/**\n * Interface representing the data of a recording.\n */\nexport interface RecordingData {\n /**\n * The value containing the recording details.\n */\n value: {\n /**\n * The recorded data as a Base64 encoded string.\n */\n recordDataBase64: Base64String;\n /**\n * The duration of the recording in milliseconds.\n */\n msDuration: number;\n /**\n * The MIME type of the recorded file.\n */\n mimeType: string;\n\n /**\n * The URI of the recording file.\n */\n uri?: string;\n };\n}\n\n/**\n * Interface representing a generic response with a boolean value.\n */\nexport interface GenericResponse {\n /**\n * The result of the operation as a boolean value.\n */\n value: boolean;\n}\n\n/**\n * Interface representing the current status of the voice recorder.\n */\nexport interface CurrentRecordingStatus {\n /**\n * The current status of the recorder, which can be one of the following values: 'RECORDING', 'PAUSED', 'INTERRUPTED', 'NONE'.\n */\n status: 'RECORDING' | 'PAUSED' | 'INTERRUPTED' | 'NONE';\n}\n\n/**\n * Event payload for voiceRecordingInterrupted event (empty - no data).\n */\nexport type VoiceRecordingInterruptedEvent = Record<string, never>;\n\n/**\n * Event payload for voiceRecordingInterruptionEnded event (empty - no data).\n */\nexport type VoiceRecordingInterruptionEndedEvent = Record<string, never>;\n\n/**\n * Interface for the VoiceRecorderPlugin which provides methods to record audio.\n */\nexport interface VoiceRecorderPlugin {\n /**\n * Checks if the current device can record audio.\n * On mobile, this function will always resolve to `{ value: true }`.\n * In a browser, it will resolve to `{ value: true }` or `{ value: false }` based on the browser's ability to record.\n * This method does not take into account the permission status, only if the browser itself is capable of recording at all.\n * @returns A promise that resolves to a GenericResponse.\n * @throws Error with code \"COULD_NOT_QUERY_PERMISSION_STATUS\" if the device cannot query the permission status.\n */\n canDeviceVoiceRecord(): Promise<GenericResponse>;\n\n /**\n * Requests audio recording permission from the user.\n * If the permission has already been provided, the promise will resolve with `{ value: true }`.\n * Otherwise, the promise will resolve to `{ value: true }` or `{ value: false }` based on the user's response.\n * @returns A promise that resolves to a GenericResponse.\n * @throws Error if the permission request fails.\n */\n requestAudioRecordingPermission(): Promise<GenericResponse>;\n\n /**\n * Checks if audio recording permission has been granted.\n * Will resolve to `{ value: true }` or `{ value: false }` based on the status of the permission.\n * The web implementation of this plugin uses the Permissions API, which is not widespread.\n * If the status of the permission cannot be checked, the promise will reject with `COULD_NOT_QUERY_PERMISSION_STATUS`.\n * In that case, use `requestAudioRecordingPermission` or `startRecording` and capture any exception that is thrown.\n * @returns A promise that resolves to a GenericResponse.\n * @throws Error with code \"COULD_NOT_QUERY_PERMISSION_STATUS\" if the device cannot query the permission status.\n */\n hasAudioRecordingPermission(): Promise<GenericResponse>;\n\n /**\n * Starts audio recording.\n * On success, the promise will resolve to { value: true }.\n * On error, the promise will reject with one of the following error codes:\n * \"MISSING_PERMISSION\", \"ALREADY_RECORDING\", \"MICROPHONE_BEING_USED\", \"DEVICE_CANNOT_VOICE_RECORD\", or \"FAILED_TO_RECORD\".\n * @param options The options for the recording.\n * @returns A promise that resolves to a GenericResponse.\n * @throws Error with one of the specified error codes if the recording cannot be started.\n */\n startRecording(options?: RecordingOptions): Promise<GenericResponse>;\n\n /**\n * Stops audio recording.\n * Will stop the recording that has been previously started.\n * If the function `startRecording` has not been called beforehand, the promise will reject with `RECORDING_HAS_NOT_STARTED`.\n * If the recording has been stopped immediately after it has been started, the promise will reject with `EMPTY_RECORDING`.\n * In a case of unknown error, the promise will reject with `FAILED_TO_FETCH_RECORDING`.\n * On iOS, if a recording interrupted by the system cannot be merged, the promise will reject with `FAILED_TO_MERGE_RECORDING`.\n * In case of success, the promise resolves to RecordingData containing the recording in base-64, the duration of the recording in milliseconds, and the MIME type.\n * @returns A promise that resolves to RecordingData.\n * @throws Error with one of the specified error codes if the recording cannot be stopped.\n */\n stopRecording(): Promise<RecordingData>;\n\n /**\n * Pauses the ongoing audio recording.\n * If the recording has not started yet, the promise will reject with an error code `RECORDING_HAS_NOT_STARTED`.\n * On success, the promise will resolve to { value: true } if the pause was successful or { value: false } if the recording is already paused.\n * On certain mobile OS versions, this function is not supported and will reject with `NOT_SUPPORTED_OS_VERSION`.\n * @returns A promise that resolves to a GenericResponse.\n * @throws Error with one of the specified error codes if the recording cannot be paused.\n */\n pauseRecording(): Promise<GenericResponse>;\n\n /**\n * Resumes a paused or interrupted audio recording.\n * If the recording has not started yet, the promise will reject with an error code `RECORDING_HAS_NOT_STARTED`.\n * On success, the promise will resolve to { value: true } if the resume was successful or { value: false } if the recording is already running.\n * On certain mobile OS versions, this function is not supported and will reject with `NOT_SUPPORTED_OS_VERSION`.\n * @returns A promise that resolves to a GenericResponse.\n * @throws Error with one of the specified error codes if the recording cannot be resumed.\n */\n resumeRecording(): Promise<GenericResponse>;\n\n /**\n * Gets the current status of the voice recorder.\n * Will resolve with one of the following values:\n * `{ status: \"NONE\" }` if the plugin is idle and waiting to start a new recording.\n * `{ status: \"RECORDING\" }` if the plugin is in the middle of recording.\n * `{ status: \"PAUSED\" }` if the recording is paused.\n * `{ status: \"INTERRUPTED\" }` if the recording was paused due to a system interruption.\n * @returns A promise that resolves to a CurrentRecordingStatus.\n * @throws Error if the status cannot be fetched.\n */\n getCurrentStatus(): Promise<CurrentRecordingStatus>;\n\n /**\n * Listen for audio recording interruptions (e.g., phone calls, other apps using microphone).\n * Available on iOS and Android only.\n *\n * @param eventName The name of the event to listen for.\n * @param listenerFunc The callback function to invoke when the event occurs.\n * @returns A promise that resolves to a PluginListenerHandle.\n */\n addListener(\n eventName: 'voiceRecordingInterrupted',\n listenerFunc: (event: VoiceRecordingInterruptedEvent) => void,\n ): Promise<PluginListenerHandle>;\n\n /**\n * Listen for audio recording interruption end events.\n * Available on iOS and Android only.\n *\n * @param eventName The name of the event to listen for.\n * @param listenerFunc The callback function to invoke when the event occurs.\n * @returns A promise that resolves to a PluginListenerHandle.\n */\n addListener(\n eventName: 'voiceRecordingInterruptionEnded',\n listenerFunc: (event: VoiceRecordingInterruptionEndedEvent) => void,\n ): Promise<PluginListenerHandle>;\n\n /**\n * Remove all listeners for this plugin.\n */\n removeAllListeners(): Promise<void>;\n}\n"]}
@@ -4,6 +4,7 @@ enum CurrentRecordingStatus: String {
4
4
 
5
5
  case RECORDING
6
6
  case PAUSED
7
+ case INTERRUPTED
7
8
  case NONE
8
9
 
9
10
  }
@@ -6,9 +6,13 @@ class CustomMediaRecorder {
6
6
  public var options: RecordOptions?
7
7
  private var recordingSession: AVAudioSession!
8
8
  private var audioRecorder: AVAudioRecorder!
9
- private var audioFilePath: URL!
9
+ private var baseAudioFilePath: URL!
10
+ private var audioFileSegments: [URL] = []
10
11
  private var originalRecordingSessionCategory: AVAudioSession.Category!
11
12
  private var status = CurrentRecordingStatus.NONE
13
+ private var interruptionObserver: NSObjectProtocol?
14
+ var onInterruptionBegan: (() -> Void)?
15
+ var onInterruptionEnded: (() -> Void)?
12
16
 
13
17
  private let settings = [
14
18
  AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
@@ -46,8 +50,10 @@ class CustomMediaRecorder {
46
50
  originalRecordingSessionCategory = recordingSession.category
47
51
  try recordingSession.setCategory(AVAudioSession.Category.playAndRecord)
48
52
  try recordingSession.setActive(true)
49
- audioFilePath = getDirectoryToSaveAudioFile().appendingPathComponent("recording-\(Int(Date().timeIntervalSince1970 * 1000)).aac")
50
- audioRecorder = try AVAudioRecorder(url: audioFilePath, settings: settings)
53
+ baseAudioFilePath = getDirectoryToSaveAudioFile().appendingPathComponent("recording-\(Int(Date().timeIntervalSince1970 * 1000)).aac")
54
+ audioFileSegments = [baseAudioFilePath]
55
+ audioRecorder = try AVAudioRecorder(url: baseAudioFilePath, settings: settings)
56
+ setupInterruptionHandling()
51
57
  audioRecorder.record()
52
58
  status = CurrentRecordingStatus.RECORDING
53
59
  return true
@@ -56,20 +62,46 @@ class CustomMediaRecorder {
56
62
  }
57
63
  }
58
64
 
59
- public func stopRecording() {
60
- do {
61
- audioRecorder.stop()
62
- try recordingSession.setActive(false)
63
- try recordingSession.setCategory(originalRecordingSessionCategory)
64
- originalRecordingSessionCategory = nil
65
- audioRecorder = nil
66
- recordingSession = nil
67
- status = CurrentRecordingStatus.NONE
68
- } catch {}
65
+ public func stopRecording(completion: @escaping (Bool) -> Void) {
66
+ removeInterruptionHandling()
67
+ audioRecorder.stop()
68
+
69
+ let finalizeStop: (Bool) -> Void = { [weak self] success in
70
+ guard let self = self else {
71
+ completion(false)
72
+ return
73
+ }
74
+
75
+ do {
76
+ try self.recordingSession.setActive(false)
77
+ try self.recordingSession.setCategory(self.originalRecordingSessionCategory)
78
+ } catch {
79
+ }
80
+
81
+ self.originalRecordingSessionCategory = nil
82
+ self.audioRecorder = nil
83
+ self.recordingSession = nil
84
+ self.status = CurrentRecordingStatus.NONE
85
+ completion(success)
86
+ }
87
+
88
+ if audioFileSegments.count > 1 {
89
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
90
+ guard let self = self else {
91
+ completion(false)
92
+ return
93
+ }
94
+ self.mergeAudioSegments { success in
95
+ finalizeStop(success)
96
+ }
97
+ }
98
+ } else {
99
+ finalizeStop(true)
100
+ }
69
101
  }
70
102
 
71
103
  public func getOutputFile() -> URL {
72
- return audioFilePath
104
+ return baseAudioFilePath
73
105
  }
74
106
 
75
107
  public func getDirectory(directory: String?) -> FileManager.SearchPathDirectory? {
@@ -97,17 +129,207 @@ class CustomMediaRecorder {
97
129
  }
98
130
 
99
131
  public func resumeRecording() -> Bool {
100
- if(status == CurrentRecordingStatus.PAUSED) {
101
- audioRecorder.record()
102
- status = CurrentRecordingStatus.RECORDING
103
- return true
104
- } else {
105
- return false
132
+ if(status == CurrentRecordingStatus.PAUSED || status == CurrentRecordingStatus.INTERRUPTED) {
133
+ let wasInterrupted = status == CurrentRecordingStatus.INTERRUPTED
134
+ do {
135
+ try recordingSession.setActive(true)
136
+ if status == CurrentRecordingStatus.INTERRUPTED {
137
+ let directory = getDirectoryToSaveAudioFile()
138
+ let timestamp = Int(Date().timeIntervalSince1970 * 1000)
139
+ let segmentNumber = audioFileSegments.count
140
+ let segmentPath = directory.appendingPathComponent("recording-\(timestamp)-segment-\(segmentNumber).aac")
141
+ audioRecorder = try AVAudioRecorder(url: segmentPath, settings: settings)
142
+ audioFileSegments.append(segmentPath)
143
+ }
144
+ audioRecorder.record()
145
+ status = CurrentRecordingStatus.RECORDING
146
+ return true
147
+ } catch {
148
+ if wasInterrupted {
149
+ try? recordingSession.setActive(false)
150
+ }
151
+ return false
152
+ }
106
153
  }
154
+
155
+ return false
107
156
  }
108
157
 
109
158
  public func getCurrentStatus() -> CurrentRecordingStatus {
110
159
  return status
111
160
  }
112
161
 
162
+ private func setupInterruptionHandling() {
163
+ interruptionObserver = NotificationCenter.default.addObserver(
164
+ forName: AVAudioSession.interruptionNotification,
165
+ object: AVAudioSession.sharedInstance(),
166
+ queue: .main
167
+ ) { [weak self] notification in
168
+ self?.handleInterruption(notification: notification)
169
+ }
170
+ }
171
+
172
+ private func removeInterruptionHandling() {
173
+ if let observer = interruptionObserver {
174
+ NotificationCenter.default.removeObserver(observer)
175
+ interruptionObserver = nil
176
+ }
177
+ }
178
+
179
+ private func handleInterruption(notification: Notification) {
180
+ guard let userInfo = notification.userInfo,
181
+ let interruptionTypeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
182
+ let interruptionType = AVAudioSession.InterruptionType(rawValue: interruptionTypeValue) else {
183
+ return
184
+ }
185
+
186
+ switch interruptionType {
187
+ case .began:
188
+ if status == CurrentRecordingStatus.RECORDING {
189
+ audioRecorder.stop()
190
+ status = CurrentRecordingStatus.INTERRUPTED
191
+ onInterruptionBegan?()
192
+ }
193
+
194
+ case .ended:
195
+ if status == CurrentRecordingStatus.INTERRUPTED {
196
+ onInterruptionEnded?()
197
+ }
198
+
199
+ @unknown default:
200
+ break
201
+ }
202
+ }
203
+
204
+ private func mergeAudioSegments(completion: @escaping (Bool) -> Void) {
205
+ if audioFileSegments.count <= 1 {
206
+ completion(true)
207
+ return
208
+ }
209
+
210
+ let basePathWithoutExtension = baseAudioFilePath.deletingPathExtension()
211
+ let mergedFilePath = basePathWithoutExtension.appendingPathExtension("m4a")
212
+ let segmentURLs = audioFileSegments
213
+ let keys = ["tracks", "duration"]
214
+ let dispatchGroup = DispatchGroup()
215
+ let syncQueue = DispatchQueue(label: "CustomMediaRecorder.assetSyncQueue")
216
+ var loadedAssets = Array<AVURLAsset?>(repeating: nil, count: segmentURLs.count)
217
+ var loadFailed = false
218
+
219
+ for (index, segmentURL) in segmentURLs.enumerated() {
220
+ let asset = AVURLAsset(url: segmentURL)
221
+ dispatchGroup.enter()
222
+ asset.loadValuesAsynchronously(forKeys: keys) {
223
+ var assetIsValid = true
224
+ for key in keys {
225
+ var error: NSError?
226
+ if asset.statusOfValue(forKey: key, error: &error) != .loaded {
227
+ assetIsValid = false
228
+ break
229
+ }
230
+ }
231
+ syncQueue.async {
232
+ if assetIsValid {
233
+ loadedAssets[index] = asset
234
+ } else {
235
+ loadFailed = true
236
+ }
237
+ dispatchGroup.leave()
238
+ }
239
+ }
240
+ }
241
+
242
+ dispatchGroup.notify(queue: DispatchQueue.global(qos: .userInitiated)) { [weak self] in
243
+ guard let self = self else {
244
+ completion(false)
245
+ return
246
+ }
247
+
248
+ var assets: [AVURLAsset] = []
249
+ var didFail = false
250
+ syncQueue.sync {
251
+ if loadFailed || loadedAssets.contains(where: { $0 == nil }) {
252
+ didFail = true
253
+ } else {
254
+ assets = loadedAssets.compactMap { $0 }
255
+ }
256
+ }
257
+
258
+ if didFail || assets.count != segmentURLs.count {
259
+ completion(false)
260
+ return
261
+ }
262
+
263
+ let composition = AVMutableComposition()
264
+ guard let compositionAudioTrack = composition.addMutableTrack(
265
+ withMediaType: .audio,
266
+ preferredTrackID: kCMPersistentTrackID_Invalid
267
+ ) else {
268
+ completion(false)
269
+ return
270
+ }
271
+
272
+ var insertTime = CMTime.zero
273
+
274
+ for asset in assets {
275
+ guard let assetTrack = asset.tracks(withMediaType: .audio).first else {
276
+ completion(false)
277
+ return
278
+ }
279
+
280
+ do {
281
+ let timeRange = CMTimeRange(start: .zero, duration: asset.duration)
282
+ try compositionAudioTrack.insertTimeRange(timeRange, of: assetTrack, at: insertTime)
283
+ insertTime = CMTimeAdd(insertTime, asset.duration)
284
+ } catch {
285
+ completion(false)
286
+ return
287
+ }
288
+ }
289
+
290
+ guard let exportSession = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetAppleM4A) else {
291
+ completion(false)
292
+ return
293
+ }
294
+
295
+ let tempDirectory = self.getDirectoryToSaveAudioFile()
296
+ let tempPath = tempDirectory.appendingPathComponent("temp-merged-\(Int(Date().timeIntervalSince1970 * 1000)).m4a")
297
+
298
+ exportSession.outputURL = tempPath
299
+ exportSession.outputFileType = .m4a
300
+
301
+ exportSession.exportAsynchronously {
302
+ guard exportSession.status == .completed else {
303
+ completion(false)
304
+ return
305
+ }
306
+
307
+ if !FileManager.default.fileExists(atPath: tempPath.path) {
308
+ completion(false)
309
+ return
310
+ }
311
+
312
+ do {
313
+ if FileManager.default.fileExists(atPath: mergedFilePath.path) {
314
+ try FileManager.default.removeItem(at: mergedFilePath)
315
+ }
316
+ try FileManager.default.moveItem(at: tempPath, to: mergedFilePath)
317
+
318
+ for segmentURL in self.audioFileSegments {
319
+ if segmentURL != mergedFilePath && FileManager.default.fileExists(atPath: segmentURL.path) {
320
+ try? FileManager.default.removeItem(at: segmentURL)
321
+ }
322
+ }
323
+ self.baseAudioFilePath = mergedFilePath
324
+ completion(true)
325
+ } catch {
326
+ if FileManager.default.fileExists(atPath: tempPath.path) {
327
+ try? FileManager.default.removeItem(at: tempPath)
328
+ }
329
+ completion(false)
330
+ }
331
+ }
332
+ }
333
+ }
334
+
113
335
  }
@@ -7,6 +7,7 @@ struct Messages {
7
7
  static let FAILED_TO_RECORD = "FAILED_TO_RECORD"
8
8
  static let RECORDING_HAS_NOT_STARTED = "RECORDING_HAS_NOT_STARTED"
9
9
  static let FAILED_TO_FETCH_RECORDING = "FAILED_TO_FETCH_RECORDING"
10
+ static let FAILED_TO_MERGE_RECORDING = "FAILED_TO_MERGE_RECORDING"
10
11
  static let EMPTY_RECORDING = "EMPTY_RECORDING"
11
12
  static let ALREADY_RECORDING = "ALREADY_RECORDING"
12
13
  static let MICROPHONE_BEING_USED = "MICROPHONE_BEING_USED"
@@ -54,6 +54,14 @@ public class VoiceRecorder: CAPPlugin, CAPBridgedPlugin {
54
54
  return
55
55
  }
56
56
 
57
+ customMediaRecorder?.onInterruptionBegan = { [weak self] in
58
+ self?.notifyListeners("voiceRecordingInterrupted", data: [:])
59
+ }
60
+
61
+ customMediaRecorder?.onInterruptionEnded = { [weak self] in
62
+ self?.notifyListeners("voiceRecordingInterruptionEnded", data: [:])
63
+ }
64
+
57
65
  let directory: String? = call.getString("directory")
58
66
  let subDirectory: String? = call.getString("subDirectory")
59
67
  let recordOptions = RecordOptions(directory: directory, subDirectory: subDirectory)
@@ -71,27 +79,42 @@ public class VoiceRecorder: CAPPlugin, CAPBridgedPlugin {
71
79
  return
72
80
  }
73
81
 
74
- customMediaRecorder?.stopRecording()
75
-
76
- let audioFileUrl = customMediaRecorder?.getOutputFile()
77
- if(audioFileUrl == nil) {
78
- customMediaRecorder = nil
79
- call.reject(Messages.FAILED_TO_FETCH_RECORDING)
80
- return
81
- }
82
-
83
- let sendDataAsBase64 = customMediaRecorder?.options?.directory == nil
84
- let recordData = RecordData(
85
- recordDataBase64: sendDataAsBase64 ? readFileAsBase64(audioFileUrl) : nil,
86
- mimeType: "audio/aac",
87
- msDuration: getMsDurationOfAudioFile(audioFileUrl),
88
- uri: sendDataAsBase64 ? nil : audioFileUrl!.path
89
- )
90
- customMediaRecorder = nil
91
- if (sendDataAsBase64 && recordData.recordDataBase64 == nil) || recordData.msDuration < 0 {
92
- call.reject(Messages.EMPTY_RECORDING)
93
- } else {
94
- call.resolve(ResponseGenerator.dataResponse(recordData.toDictionary()))
82
+ customMediaRecorder?.stopRecording { [weak self] stopSuccess in
83
+ DispatchQueue.main.async {
84
+ guard let self = self else {
85
+ call.reject(Messages.FAILED_TO_FETCH_RECORDING)
86
+ return
87
+ }
88
+
89
+ if !stopSuccess {
90
+ self.customMediaRecorder = nil
91
+ call.reject(Messages.FAILED_TO_MERGE_RECORDING)
92
+ return
93
+ }
94
+
95
+ let audioFileUrl = self.customMediaRecorder?.getOutputFile()
96
+ if(audioFileUrl == nil) {
97
+ self.customMediaRecorder = nil
98
+ call.reject(Messages.FAILED_TO_FETCH_RECORDING)
99
+ return
100
+ }
101
+
102
+ let fileExtension = audioFileUrl!.pathExtension.lowercased()
103
+ let mimeType = fileExtension == "m4a" ? "audio/mp4" : "audio/aac"
104
+ let sendDataAsBase64 = self.customMediaRecorder?.options?.directory == nil
105
+ let recordData = RecordData(
106
+ recordDataBase64: sendDataAsBase64 ? self.readFileAsBase64(audioFileUrl) : nil,
107
+ mimeType: mimeType,
108
+ msDuration: self.getMsDurationOfAudioFile(audioFileUrl),
109
+ uri: sendDataAsBase64 ? nil : audioFileUrl!.path
110
+ )
111
+ self.customMediaRecorder = nil
112
+ if (sendDataAsBase64 && recordData.recordDataBase64 == nil) || recordData.msDuration < 0 {
113
+ call.reject(Messages.EMPTY_RECORDING)
114
+ } else {
115
+ call.resolve(ResponseGenerator.dataResponse(recordData.toDictionary()))
116
+ }
117
+ }
95
118
  }
96
119
  }
97
120
 
package/package.json CHANGED
@@ -13,7 +13,7 @@
13
13
  "url": "https://github.com/independo-gmbh/capacitor-voice-recorder.git"
14
14
  },
15
15
  "description": "Capacitor plugin for voice recording",
16
- "version": "8.0.2-dev.1",
16
+ "version": "8.1.0-dev.1",
17
17
  "devDependencies": {
18
18
  "@capacitor/android": "^8.0.0",
19
19
  "conventional-changelog-conventionalcommits": "^9.1.0",