@exode-team/react-recorder 1.1.11 → 1.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -28,6 +28,56 @@ const formatTime = (seconds) => {
28
28
  return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
29
29
  };
30
30
 
31
+ /**
32
+ * AudioRecorder constants
33
+ *
34
+ * @author: exode <hello@exode.ru>
35
+ */
36
+ const AUDIO_LEVEL_BARS = 40;
37
+ /** Waveform decoding resamples to this rate — only the amplitude envelope matters */
38
+ const WAVEFORM_SAMPLE_RATE = 44100;
39
+ const createInitialLevels = () => Array.from({ length: AUDIO_LEVEL_BARS }, () => 0);
40
+ const TIMELINE_SAMPLE_INTERVAL = 150;
41
+ const TIMELINE_BAR_WIDTH = 3;
42
+ const TIMELINE_BAR_GAP = 2;
43
+ const TIMELINE_BAR_MIN_HEIGHT = 3;
44
+ const TIMELINE_BAR_MAX_HEIGHT = 24;
45
+
46
+ /**
47
+ * Pseudo waveform utilities
48
+ *
49
+ * @author: exode <hello@exode.ru>
50
+ */
51
+ /** Deterministic generator: the same source always draws the same waveform */
52
+ const seededRandom = (seed) => {
53
+ let state = Math.abs(Math.floor(seed)) % 2147483647 || 1;
54
+ return () => (state = state * 16807 % 2147483647) / 2147483647;
55
+ };
56
+ const hashString = (value) => {
57
+ let hash = 0;
58
+ for (let i = 0; i < value.length; i += 1) {
59
+ hash = (hash * 31 + value.charCodeAt(i)) | 0;
60
+ }
61
+ return hash;
62
+ };
63
+ /**
64
+ * Decorative levels for runtimes that cannot decode audio at all — WebAudio
65
+ * rejects every mp3 inside iPad apps running on macOS. Purely cosmetic: it
66
+ * stands in for a flat line and says nothing about the recording itself.
67
+ */
68
+ const createPseudoLevels = (seed, barCount = AUDIO_LEVEL_BARS) => {
69
+ const random = seededRandom(hashString(seed));
70
+ const levels = [];
71
+ let level = 45 + random() * 25;
72
+ for (let i = 0; i < barCount; i += 1) {
73
+ /** Smooth drift instead of pure noise, so it reads as speech rather than static */
74
+ level = clampValue(level + (random() - 0.5) * 42, 14);
75
+ /** Slight fade at both ends — that is how a real recording usually looks */
76
+ levels.push(level * (0.72 + 0.28 * Math.sin((i / (barCount - 1)) * Math.PI)));
77
+ }
78
+ return levels;
79
+ };
80
+
31
81
  /**
32
82
  * Microphone platform adapter
33
83
  *
@@ -133,6 +183,12 @@ const clearMediaSession = () => {
133
183
  };
134
184
  const createAudioPlayback = (url, callbacks) => {
135
185
  const audio = new Audio(url);
186
+ /**
187
+ * A detached element is not reliably rendered by every WebKit runtime
188
+ * (notably iPad apps running on macOS), so keep it in the document.
189
+ */
190
+ audio.style.display = 'none';
191
+ document.body.appendChild(audio);
136
192
  audio.onloadedmetadata = () => {
137
193
  callbacks.onLoadedMetadata(audio.duration);
138
194
  };
@@ -161,6 +217,7 @@ const createAudioPlayback = (url, callbacks) => {
161
217
  };
162
218
  const cleanupAudio = (audio) => {
163
219
  audio.pause();
220
+ audio.remove();
164
221
  audio.src = '';
165
222
  audio.onended = null;
166
223
  audio.ontimeupdate = null;
@@ -176,19 +233,6 @@ const revokeObjectURL = (url) => {
176
233
  URL.revokeObjectURL(url);
177
234
  };
178
235
 
179
- /**
180
- * AudioRecorder constants
181
- *
182
- * @author: exode <hello@exode.ru>
183
- */
184
- const AUDIO_LEVEL_BARS = 40;
185
- const createInitialLevels = () => Array.from({ length: AUDIO_LEVEL_BARS }, () => 0);
186
- const TIMELINE_SAMPLE_INTERVAL = 150;
187
- const TIMELINE_BAR_WIDTH = 3;
188
- const TIMELINE_BAR_GAP = 2;
189
- const TIMELINE_BAR_MIN_HEIGHT = 3;
190
- const TIMELINE_BAR_MAX_HEIGHT = 24;
191
-
192
236
  /**
193
237
  * MPEG frame scanner
194
238
  *
@@ -288,32 +332,32 @@ const decodeSkippingJunk = async (context, blob) => {
288
332
  }
289
333
  };
290
334
  const decodeAudioLevels = async (blob, barCount = AUDIO_LEVEL_BARS) => {
291
- const context = new AudioContext();
292
- try {
293
- const audioBuffer = await decodeSkippingJunk(context, blob);
294
- const channelData = audioBuffer.getChannelData(0);
295
- if (!channelData.length) {
296
- return Array.from({ length: barCount }, () => 0);
297
- }
298
- const levels = [];
299
- const segmentLength = Math.max(1, Math.floor(channelData.length / barCount));
300
- for (let i = 0; i < barCount; i += 1) {
301
- const segmentStart = i * segmentLength;
302
- let sum = 0;
303
- for (let j = 0; j < segmentLength; j += 1) {
304
- const sample = channelData[segmentStart + j];
305
- if (sample !== undefined) {
306
- sum += Math.abs(sample);
307
- }
335
+ /**
336
+ * Offline rendering keeps the audio hardware untouched. Runtimes that
337
+ * cannot decode at all (iPad apps on macOS reject every mp3) fall through
338
+ * to the caller, which draws a placeholder waveform instead.
339
+ */
340
+ const context = new OfflineAudioContext(1, 1, WAVEFORM_SAMPLE_RATE);
341
+ const audioBuffer = await decodeSkippingJunk(context, blob);
342
+ const channelData = audioBuffer.getChannelData(0);
343
+ if (!channelData.length) {
344
+ return Array.from({ length: barCount }, () => 0);
345
+ }
346
+ const levels = [];
347
+ const segmentLength = Math.max(1, Math.floor(channelData.length / barCount));
348
+ for (let i = 0; i < barCount; i += 1) {
349
+ const segmentStart = i * segmentLength;
350
+ let sum = 0;
351
+ for (let j = 0; j < segmentLength; j += 1) {
352
+ const sample = channelData[segmentStart + j];
353
+ if (sample !== undefined) {
354
+ sum += Math.abs(sample);
308
355
  }
309
- const average = sum / segmentLength;
310
- levels.push(Math.min(100, average * 200));
311
356
  }
312
- return levels;
313
- }
314
- finally {
315
- await context.close();
357
+ const average = sum / segmentLength;
358
+ levels.push(Math.min(100, average * 200));
316
359
  }
360
+ return levels;
317
361
  };
318
362
 
319
363
  /**
@@ -961,18 +1005,12 @@ const useAudioBlobLoader = (options) => {
961
1005
  const audioRef = useRef(null);
962
1006
  const objectUrlRef = useRef(null);
963
1007
  const abortControllerRef = useRef(null);
964
- const failedSrcRef = useRef(null);
965
1008
  const waveformTokenRef = useRef(0);
966
1009
  const waveformDataRef = useRef(waveformData);
967
1010
  const callbacksRef = useRef(playbackCallbacks);
968
1011
  waveformDataRef.current = waveformData;
969
1012
  callbacksRef.current = playbackCallbacks;
970
- const createAudioFromBlob = useCallback((blob) => {
971
- if (objectUrlRef.current) {
972
- revokeObjectURL(objectUrlRef.current);
973
- }
974
- const url = createObjectURL(blob);
975
- objectUrlRef.current = url;
1013
+ const createAudioFromUrl = useCallback((url) => {
976
1014
  const { audio } = createAudioPlayback(url, {
977
1015
  onLoadedMetadata: (dur) => callbacksRef.current.onLoadedMetadata(dur),
978
1016
  onTimeUpdate: (time) => callbacksRef.current.onTimeUpdate(time),
@@ -988,13 +1026,22 @@ const useAudioBlobLoader = (options) => {
988
1026
  audioRef.current = audio;
989
1027
  setIsLoaded(true);
990
1028
  }, []);
1029
+ /** Local recordings have no url of their own, so they still go through a blob */
1030
+ const createAudioFromBlob = useCallback((blob) => {
1031
+ if (objectUrlRef.current) {
1032
+ revokeObjectURL(objectUrlRef.current);
1033
+ }
1034
+ objectUrlRef.current = createObjectURL(blob);
1035
+ createAudioFromUrl(objectUrlRef.current);
1036
+ }, [createAudioFromUrl]);
991
1037
  /**
992
1038
  * Waveform is cosmetic: some files (e.g. mp3 with junk before the first
993
1039
  * frame) fail to decode in Safari while playing fine, so a decode error
994
1040
  * must never block playback. Token keeps only the latest decode.
995
1041
  */
996
- const applyWaveformLevels = useCallback(async (blob) => {
997
- if (waveformData) {
1042
+ const applyWaveformLevels = useCallback(async (blob, seed) => {
1043
+ /** Read through the ref: levels may arrive while the bytes are still loading */
1044
+ if (waveformDataRef.current) {
998
1045
  return;
999
1046
  }
1000
1047
  waveformTokenRef.current += 1;
@@ -1007,8 +1054,12 @@ const useAudioBlobLoader = (options) => {
1007
1054
  }
1008
1055
  catch (error) {
1009
1056
  console.warn('[voice-player] Failed to decode waveform', error);
1057
+ /** Some runtimes decode nothing at all — draw a placeholder instead of a flat line */
1058
+ if (token === waveformTokenRef.current) {
1059
+ setWaveformLevels(createPseudoLevels(seed));
1060
+ }
1010
1061
  }
1011
- }, [waveformData]);
1062
+ }, []);
1012
1063
  const mountFromBlob = useCallback(async (blob) => {
1013
1064
  if (audioRef.current) {
1014
1065
  return;
@@ -1023,53 +1074,51 @@ const useAudioBlobLoader = (options) => {
1023
1074
  finally {
1024
1075
  setIsLoading(false);
1025
1076
  }
1026
- void applyWaveformLevels(blob);
1077
+ void applyWaveformLevels(blob, `${blob.size}`);
1027
1078
  }, [applyWaveformLevels, createAudioFromBlob]);
1028
- const ensureAudioLoaded = useCallback(async (options) => {
1079
+ /** Waveform bytes are cosmetic, so nothing here may delay playback */
1080
+ const loadWaveformSource = useCallback(async (url, cached) => {
1029
1081
  var _a;
1030
- if (audioRef.current) {
1082
+ if (waveformDataRef.current) {
1031
1083
  return;
1032
1084
  }
1033
- if (failedSrcRef.current === src) {
1034
- if (!(options === null || options === void 0 ? void 0 : options.retryFailed)) {
1035
- return;
1036
- }
1037
- failedSrcRef.current = null;
1038
- }
1039
- if (preloadedBlob) {
1040
- return mountFromBlob(preloadedBlob);
1041
- }
1042
- if (!src) {
1043
- return;
1085
+ if (cached) {
1086
+ return applyWaveformLevels(cached, url);
1044
1087
  }
1045
1088
  (_a = abortControllerRef.current) === null || _a === void 0 ? void 0 : _a.abort();
1046
1089
  const controller = new AbortController();
1047
1090
  abortControllerRef.current = controller;
1048
- setIsLoading(true);
1049
1091
  try {
1050
- const blob = await fetchAudioBlob(src, controller.signal);
1051
- if (controller.signal.aborted) {
1052
- return;
1092
+ const blob = await fetchAudioBlob(url, controller.signal);
1093
+ if (!controller.signal.aborted) {
1094
+ await applyWaveformLevels(blob, url);
1053
1095
  }
1054
- createAudioFromBlob(blob);
1055
- void applyWaveformLevels(blob);
1056
1096
  }
1057
1097
  catch (error) {
1058
1098
  if (error instanceof DOMException && error.name === 'AbortError') {
1059
1099
  return;
1060
1100
  }
1061
- failedSrcRef.current = src;
1062
- console.warn('[voice-player] Failed to load audio', error);
1101
+ console.warn('[voice-player] Failed to fetch waveform source', error);
1063
1102
  }
1064
- finally {
1065
- if (!controller.signal.aborted) {
1066
- setIsLoading(false);
1067
- }
1103
+ }, [applyWaveformLevels]);
1104
+ const ensureAudioLoaded = useCallback(async () => {
1105
+ if (audioRef.current) {
1106
+ return;
1068
1107
  }
1069
- }, [src, preloadedBlob, mountFromBlob, applyWaveformLevels, createAudioFromBlob]);
1108
+ /** Only a recording that has not been uploaded yet has no url of its own */
1109
+ if (!src) {
1110
+ return preloadedBlob ? mountFromBlob(preloadedBlob) : undefined;
1111
+ }
1112
+ /**
1113
+ * Remote files play straight from their url: it streams with range
1114
+ * requests, keeps the user gesture (no await before play) and avoids
1115
+ * blob sources, which some WebKit runtimes refuse to render.
1116
+ */
1117
+ createAudioFromUrl(src);
1118
+ void loadWaveformSource(src, preloadedBlob);
1119
+ }, [src, preloadedBlob, mountFromBlob, loadWaveformSource, createAudioFromUrl]);
1070
1120
  /** Cleanup on src change or unmount */
1071
1121
  useEffect(() => {
1072
- failedSrcRef.current = null;
1073
1122
  /** Drop the previous waveform so a failed decode cannot leave a foreign one */
1074
1123
  setWaveformLevels(waveformDataRef.current || createInitialLevels());
1075
1124
  return () => {
@@ -1087,11 +1136,12 @@ const useAudioBlobLoader = (options) => {
1087
1136
  setIsLoaded(false);
1088
1137
  };
1089
1138
  }, [src]);
1139
+ /** A cached recording mounts on its own only while it has no url yet */
1090
1140
  useEffect(() => {
1091
- if (preloadedBlob && !audioRef.current) {
1141
+ if (preloadedBlob && !src && !audioRef.current) {
1092
1142
  void mountFromBlob(preloadedBlob);
1093
1143
  }
1094
- }, [preloadedBlob, mountFromBlob]);
1144
+ }, [src, preloadedBlob, mountFromBlob]);
1095
1145
  useEffect(() => {
1096
1146
  if (waveformData) {
1097
1147
  waveformTokenRef.current += 1;
@@ -1200,7 +1250,7 @@ const useVoiceMessagePlayer = (options) => {
1200
1250
  return;
1201
1251
  }
1202
1252
  audioPlaybackManager.stopOthers(stopThis);
1203
- void ensureAudioLoaded({ retryFailed: true }).then(() => {
1253
+ void ensureAudioLoaded().then(() => {
1204
1254
  const audio = audioRef.current;
1205
1255
  if (audio) {
1206
1256
  audio.play()
@@ -1260,7 +1310,7 @@ const useVoiceMessagePlayer = (options) => {
1260
1310
  .catch(() => setIsPlaying(false));
1261
1311
  }
1262
1312
  else {
1263
- void ensureAudioLoaded({ retryFailed: true }).then(() => {
1313
+ void ensureAudioLoaded().then(() => {
1264
1314
  const loadedAudio = audioRef.current;
1265
1315
  if (loadedAudio) {
1266
1316
  audioPlaybackManager.stopOthers(stopThis);