@exode-team/react-recorder 1.1.12 → 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,21 +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
- /** Waveform decoding resamples to this rate — only the amplitude envelope matters */
186
- const WAVEFORM_SAMPLE_RATE = 44100;
187
- const createInitialLevels = () => Array.from({ length: AUDIO_LEVEL_BARS }, () => 0);
188
- const TIMELINE_SAMPLE_INTERVAL = 150;
189
- const TIMELINE_BAR_WIDTH = 3;
190
- const TIMELINE_BAR_GAP = 2;
191
- const TIMELINE_BAR_MIN_HEIGHT = 3;
192
- const TIMELINE_BAR_MAX_HEIGHT = 24;
193
-
194
236
  /**
195
237
  * MPEG frame scanner
196
238
  *
@@ -291,10 +333,9 @@ const decodeSkippingJunk = async (context, blob) => {
291
333
  };
292
334
  const decodeAudioLevels = async (blob, barCount = AUDIO_LEVEL_BARS) => {
293
335
  /**
294
- * Offline context stays out of the audio session. A realtime AudioContext
295
- * registers as a WebAudio source, which pins WebKit to the AmbientSound
296
- * category and leaves media playback silent inside iOS apps — including
297
- * iPad apps running on macOS.
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.
298
339
  */
299
340
  const context = new OfflineAudioContext(1, 1, WAVEFORM_SAMPLE_RATE);
300
341
  const audioBuffer = await decodeSkippingJunk(context, blob);
@@ -964,18 +1005,12 @@ const useAudioBlobLoader = (options) => {
964
1005
  const audioRef = useRef(null);
965
1006
  const objectUrlRef = useRef(null);
966
1007
  const abortControllerRef = useRef(null);
967
- const failedSrcRef = useRef(null);
968
1008
  const waveformTokenRef = useRef(0);
969
1009
  const waveformDataRef = useRef(waveformData);
970
1010
  const callbacksRef = useRef(playbackCallbacks);
971
1011
  waveformDataRef.current = waveformData;
972
1012
  callbacksRef.current = playbackCallbacks;
973
- const createAudioFromBlob = useCallback((blob) => {
974
- if (objectUrlRef.current) {
975
- revokeObjectURL(objectUrlRef.current);
976
- }
977
- const url = createObjectURL(blob);
978
- objectUrlRef.current = url;
1013
+ const createAudioFromUrl = useCallback((url) => {
979
1014
  const { audio } = createAudioPlayback(url, {
980
1015
  onLoadedMetadata: (dur) => callbacksRef.current.onLoadedMetadata(dur),
981
1016
  onTimeUpdate: (time) => callbacksRef.current.onTimeUpdate(time),
@@ -991,13 +1026,22 @@ const useAudioBlobLoader = (options) => {
991
1026
  audioRef.current = audio;
992
1027
  setIsLoaded(true);
993
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]);
994
1037
  /**
995
1038
  * Waveform is cosmetic: some files (e.g. mp3 with junk before the first
996
1039
  * frame) fail to decode in Safari while playing fine, so a decode error
997
1040
  * must never block playback. Token keeps only the latest decode.
998
1041
  */
999
- const applyWaveformLevels = useCallback(async (blob) => {
1000
- 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) {
1001
1045
  return;
1002
1046
  }
1003
1047
  waveformTokenRef.current += 1;
@@ -1010,8 +1054,12 @@ const useAudioBlobLoader = (options) => {
1010
1054
  }
1011
1055
  catch (error) {
1012
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
+ }
1013
1061
  }
1014
- }, [waveformData]);
1062
+ }, []);
1015
1063
  const mountFromBlob = useCallback(async (blob) => {
1016
1064
  if (audioRef.current) {
1017
1065
  return;
@@ -1026,53 +1074,51 @@ const useAudioBlobLoader = (options) => {
1026
1074
  finally {
1027
1075
  setIsLoading(false);
1028
1076
  }
1029
- void applyWaveformLevels(blob);
1077
+ void applyWaveformLevels(blob, `${blob.size}`);
1030
1078
  }, [applyWaveformLevels, createAudioFromBlob]);
1031
- const ensureAudioLoaded = useCallback(async (options) => {
1079
+ /** Waveform bytes are cosmetic, so nothing here may delay playback */
1080
+ const loadWaveformSource = useCallback(async (url, cached) => {
1032
1081
  var _a;
1033
- if (audioRef.current) {
1082
+ if (waveformDataRef.current) {
1034
1083
  return;
1035
1084
  }
1036
- if (failedSrcRef.current === src) {
1037
- if (!(options === null || options === void 0 ? void 0 : options.retryFailed)) {
1038
- return;
1039
- }
1040
- failedSrcRef.current = null;
1041
- }
1042
- if (preloadedBlob) {
1043
- return mountFromBlob(preloadedBlob);
1044
- }
1045
- if (!src) {
1046
- return;
1085
+ if (cached) {
1086
+ return applyWaveformLevels(cached, url);
1047
1087
  }
1048
1088
  (_a = abortControllerRef.current) === null || _a === void 0 ? void 0 : _a.abort();
1049
1089
  const controller = new AbortController();
1050
1090
  abortControllerRef.current = controller;
1051
- setIsLoading(true);
1052
1091
  try {
1053
- const blob = await fetchAudioBlob(src, controller.signal);
1054
- if (controller.signal.aborted) {
1055
- return;
1092
+ const blob = await fetchAudioBlob(url, controller.signal);
1093
+ if (!controller.signal.aborted) {
1094
+ await applyWaveformLevels(blob, url);
1056
1095
  }
1057
- createAudioFromBlob(blob);
1058
- void applyWaveformLevels(blob);
1059
1096
  }
1060
1097
  catch (error) {
1061
1098
  if (error instanceof DOMException && error.name === 'AbortError') {
1062
1099
  return;
1063
1100
  }
1064
- failedSrcRef.current = src;
1065
- console.warn('[voice-player] Failed to load audio', error);
1101
+ console.warn('[voice-player] Failed to fetch waveform source', error);
1066
1102
  }
1067
- finally {
1068
- if (!controller.signal.aborted) {
1069
- setIsLoading(false);
1070
- }
1103
+ }, [applyWaveformLevels]);
1104
+ const ensureAudioLoaded = useCallback(async () => {
1105
+ if (audioRef.current) {
1106
+ return;
1071
1107
  }
1072
- }, [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]);
1073
1120
  /** Cleanup on src change or unmount */
1074
1121
  useEffect(() => {
1075
- failedSrcRef.current = null;
1076
1122
  /** Drop the previous waveform so a failed decode cannot leave a foreign one */
1077
1123
  setWaveformLevels(waveformDataRef.current || createInitialLevels());
1078
1124
  return () => {
@@ -1090,11 +1136,12 @@ const useAudioBlobLoader = (options) => {
1090
1136
  setIsLoaded(false);
1091
1137
  };
1092
1138
  }, [src]);
1139
+ /** A cached recording mounts on its own only while it has no url yet */
1093
1140
  useEffect(() => {
1094
- if (preloadedBlob && !audioRef.current) {
1141
+ if (preloadedBlob && !src && !audioRef.current) {
1095
1142
  void mountFromBlob(preloadedBlob);
1096
1143
  }
1097
- }, [preloadedBlob, mountFromBlob]);
1144
+ }, [src, preloadedBlob, mountFromBlob]);
1098
1145
  useEffect(() => {
1099
1146
  if (waveformData) {
1100
1147
  waveformTokenRef.current += 1;
@@ -1203,7 +1250,7 @@ const useVoiceMessagePlayer = (options) => {
1203
1250
  return;
1204
1251
  }
1205
1252
  audioPlaybackManager.stopOthers(stopThis);
1206
- void ensureAudioLoaded({ retryFailed: true }).then(() => {
1253
+ void ensureAudioLoaded().then(() => {
1207
1254
  const audio = audioRef.current;
1208
1255
  if (audio) {
1209
1256
  audio.play()
@@ -1263,7 +1310,7 @@ const useVoiceMessagePlayer = (options) => {
1263
1310
  .catch(() => setIsPlaying(false));
1264
1311
  }
1265
1312
  else {
1266
- void ensureAudioLoaded({ retryFailed: true }).then(() => {
1313
+ void ensureAudioLoaded().then(() => {
1267
1314
  const loadedAudio = audioRef.current;
1268
1315
  if (loadedAudio) {
1269
1316
  audioPlaybackManager.stopOthers(stopThis);