@exode-team/react-recorder 1.1.10 → 1.1.11

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
@@ -189,6 +189,64 @@ const TIMELINE_BAR_GAP = 2;
189
189
  const TIMELINE_BAR_MIN_HEIGHT = 3;
190
190
  const TIMELINE_BAR_MAX_HEIGHT = 24;
191
191
 
192
+ /**
193
+ * MPEG frame scanner
194
+ *
195
+ * @author: exode <hello@exode.ru>
196
+ */
197
+ const MPEG_LAYER_III = 1;
198
+ const MPEG_SAMPLE_RATES = [44100, 48000, 32000];
199
+ const MPEG_BITRATES_V1 = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0];
200
+ const MPEG_BITRATES_V2 = [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0];
201
+ /** Length of the Layer III frame at the given offset, or 0 when it is not one */
202
+ const readFrameLength = (bytes, offset) => {
203
+ if (offset + 3 >= bytes.length || bytes[offset] !== 0xff || (bytes[offset + 1] & 0xe0) !== 0xe0) {
204
+ return 0;
205
+ }
206
+ /** Version ids: 0 = MPEG 2.5, 1 = reserved, 2 = MPEG 2, 3 = MPEG 1 */
207
+ const version = (bytes[offset + 1] >> 3) & 0x03;
208
+ const layer = (bytes[offset + 1] >> 1) & 0x03;
209
+ const bitrateIndex = (bytes[offset + 2] >> 4) & 0x0f;
210
+ const sampleRateIndex = (bytes[offset + 2] >> 2) & 0x03;
211
+ const isReserved = version === 1
212
+ || layer !== MPEG_LAYER_III
213
+ || sampleRateIndex === 3
214
+ || bitrateIndex === 0
215
+ || bitrateIndex === 0x0f;
216
+ if (isReserved) {
217
+ return 0;
218
+ }
219
+ const isVersion1 = version === 3;
220
+ const padding = (bytes[offset + 2] >> 1) & 0x01;
221
+ const bitrate = (isVersion1 ? MPEG_BITRATES_V1 : MPEG_BITRATES_V2)[bitrateIndex] * 1000;
222
+ const sampleRate = MPEG_SAMPLE_RATES[sampleRateIndex] / (isVersion1 ? 1 : version === 2 ? 2 : 4);
223
+ return Math.floor((isVersion1 ? 144 : 72) * bitrate / sampleRate) + padding;
224
+ };
225
+ /**
226
+ * Some mp3 (e.g. converted from wma) keep junk between the ID3 tag and the
227
+ * first MPEG frame: media elements skip it, the WebAudio decoder does not.
228
+ * Returns the offset of the first real frame, or 0 when there is none.
229
+ */
230
+ const findFirstFrameOffset = (bytes) => {
231
+ let start = 0;
232
+ /** ID3v2 header: "ID3" + version + flags + 4 syncsafe size bytes */
233
+ if (bytes.length >= 10 && bytes[0] === 0x49 && bytes[1] === 0x44 && bytes[2] === 0x33) {
234
+ start = 10 + ((bytes[6] << 21) | (bytes[7] << 14) | (bytes[8] << 7) | bytes[9]);
235
+ /** ID3v2.4 may append a 10 byte footer copy of the header */
236
+ if (bytes[5] & 0x10) {
237
+ start += 10;
238
+ }
239
+ }
240
+ for (let i = start; i < bytes.length - 3; i += 1) {
241
+ const length = readFrameLength(bytes, i);
242
+ /** Junk can open with a valid looking header, so require the next frame to follow */
243
+ if (length && readFrameLength(bytes, i + length)) {
244
+ return i;
245
+ }
246
+ }
247
+ return 0;
248
+ };
249
+
192
250
  /**
193
251
  * Audio analyzer platform adapter
194
252
  *
@@ -215,11 +273,24 @@ const readFrequencyLevels = (analyser, barCount = AUDIO_LEVEL_BARS) => {
215
273
  }
216
274
  return levels;
217
275
  };
276
+ /** decodeAudioData detaches the buffer, so the retry re-reads the blob */
277
+ const decodeSkippingJunk = async (context, blob) => {
278
+ try {
279
+ return await context.decodeAudioData(await blob.arrayBuffer());
280
+ }
281
+ catch (error) {
282
+ const bytes = new Uint8Array(await blob.arrayBuffer());
283
+ const offset = findFirstFrameOffset(bytes);
284
+ if (!offset) {
285
+ throw error;
286
+ }
287
+ return context.decodeAudioData(bytes.buffer.slice(offset));
288
+ }
289
+ };
218
290
  const decodeAudioLevels = async (blob, barCount = AUDIO_LEVEL_BARS) => {
219
291
  const context = new AudioContext();
220
292
  try {
221
- const arrayBuffer = await blob.arrayBuffer();
222
- const audioBuffer = await context.decodeAudioData(arrayBuffer);
293
+ const audioBuffer = await decodeSkippingJunk(context, blob);
223
294
  const channelData = audioBuffer.getChannelData(0);
224
295
  if (!channelData.length) {
225
296
  return Array.from({ length: barCount }, () => 0);
@@ -256,6 +327,9 @@ const fetchAudioBlob = async (url, signal) => {
256
327
  mode: 'cors',
257
328
  credentials: 'omit',
258
329
  });
330
+ if (!response.ok) {
331
+ throw new Error(`Failed to fetch audio: ${response.status}`);
332
+ }
259
333
  return response.blob();
260
334
  };
261
335
 
@@ -888,7 +962,10 @@ const useAudioBlobLoader = (options) => {
888
962
  const objectUrlRef = useRef(null);
889
963
  const abortControllerRef = useRef(null);
890
964
  const failedSrcRef = useRef(null);
965
+ const waveformTokenRef = useRef(0);
966
+ const waveformDataRef = useRef(waveformData);
891
967
  const callbacksRef = useRef(playbackCallbacks);
968
+ waveformDataRef.current = waveformData;
892
969
  callbacksRef.current = playbackCallbacks;
893
970
  const createAudioFromBlob = useCallback((blob) => {
894
971
  if (objectUrlRef.current) {
@@ -911,15 +988,33 @@ const useAudioBlobLoader = (options) => {
911
988
  audioRef.current = audio;
912
989
  setIsLoaded(true);
913
990
  }, []);
991
+ /**
992
+ * Waveform is cosmetic: some files (e.g. mp3 with junk before the first
993
+ * frame) fail to decode in Safari while playing fine, so a decode error
994
+ * must never block playback. Token keeps only the latest decode.
995
+ */
996
+ const applyWaveformLevels = useCallback(async (blob) => {
997
+ if (waveformData) {
998
+ return;
999
+ }
1000
+ waveformTokenRef.current += 1;
1001
+ const token = waveformTokenRef.current;
1002
+ try {
1003
+ const levels = await decodeAudioLevels(blob);
1004
+ if (token === waveformTokenRef.current) {
1005
+ setWaveformLevels(levels);
1006
+ }
1007
+ }
1008
+ catch (error) {
1009
+ console.warn('[voice-player] Failed to decode waveform', error);
1010
+ }
1011
+ }, [waveformData]);
914
1012
  const mountFromBlob = useCallback(async (blob) => {
915
1013
  if (audioRef.current) {
916
1014
  return;
917
1015
  }
918
1016
  setIsLoading(true);
919
1017
  try {
920
- if (!waveformData) {
921
- setWaveformLevels(await decodeAudioLevels(blob));
922
- }
923
1018
  createAudioFromBlob(blob);
924
1019
  }
925
1020
  catch (error) {
@@ -928,12 +1023,19 @@ const useAudioBlobLoader = (options) => {
928
1023
  finally {
929
1024
  setIsLoading(false);
930
1025
  }
931
- }, [waveformData, createAudioFromBlob]);
932
- const ensureAudioLoaded = useCallback(async () => {
1026
+ void applyWaveformLevels(blob);
1027
+ }, [applyWaveformLevels, createAudioFromBlob]);
1028
+ const ensureAudioLoaded = useCallback(async (options) => {
933
1029
  var _a;
934
- if (audioRef.current || failedSrcRef.current === src) {
1030
+ if (audioRef.current) {
935
1031
  return;
936
1032
  }
1033
+ if (failedSrcRef.current === src) {
1034
+ if (!(options === null || options === void 0 ? void 0 : options.retryFailed)) {
1035
+ return;
1036
+ }
1037
+ failedSrcRef.current = null;
1038
+ }
937
1039
  if (preloadedBlob) {
938
1040
  return mountFromBlob(preloadedBlob);
939
1041
  }
@@ -949,14 +1051,8 @@ const useAudioBlobLoader = (options) => {
949
1051
  if (controller.signal.aborted) {
950
1052
  return;
951
1053
  }
952
- if (!waveformData) {
953
- const levels = await decodeAudioLevels(blob);
954
- if (controller.signal.aborted) {
955
- return;
956
- }
957
- setWaveformLevels(levels);
958
- }
959
1054
  createAudioFromBlob(blob);
1055
+ void applyWaveformLevels(blob);
960
1056
  }
961
1057
  catch (error) {
962
1058
  if (error instanceof DOMException && error.name === 'AbortError') {
@@ -970,13 +1066,16 @@ const useAudioBlobLoader = (options) => {
970
1066
  setIsLoading(false);
971
1067
  }
972
1068
  }
973
- }, [src, waveformData, preloadedBlob, mountFromBlob, createAudioFromBlob]);
1069
+ }, [src, preloadedBlob, mountFromBlob, applyWaveformLevels, createAudioFromBlob]);
974
1070
  /** Cleanup on src change or unmount */
975
1071
  useEffect(() => {
976
1072
  failedSrcRef.current = null;
1073
+ /** Drop the previous waveform so a failed decode cannot leave a foreign one */
1074
+ setWaveformLevels(waveformDataRef.current || createInitialLevels());
977
1075
  return () => {
978
1076
  var _a;
979
1077
  (_a = abortControllerRef.current) === null || _a === void 0 ? void 0 : _a.abort();
1078
+ waveformTokenRef.current += 1;
980
1079
  if (audioRef.current) {
981
1080
  cleanupAudio(audioRef.current);
982
1081
  audioRef.current = null;
@@ -995,6 +1094,7 @@ const useAudioBlobLoader = (options) => {
995
1094
  }, [preloadedBlob, mountFromBlob]);
996
1095
  useEffect(() => {
997
1096
  if (waveformData) {
1097
+ waveformTokenRef.current += 1;
998
1098
  setWaveformLevels(waveformData);
999
1099
  }
1000
1100
  }, [waveformData]);
@@ -1100,7 +1200,7 @@ const useVoiceMessagePlayer = (options) => {
1100
1200
  return;
1101
1201
  }
1102
1202
  audioPlaybackManager.stopOthers(stopThis);
1103
- void ensureAudioLoaded().then(() => {
1203
+ void ensureAudioLoaded({ retryFailed: true }).then(() => {
1104
1204
  const audio = audioRef.current;
1105
1205
  if (audio) {
1106
1206
  audio.play()
@@ -1160,7 +1260,7 @@ const useVoiceMessagePlayer = (options) => {
1160
1260
  .catch(() => setIsPlaying(false));
1161
1261
  }
1162
1262
  else {
1163
- void ensureAudioLoaded().then(() => {
1263
+ void ensureAudioLoaded({ retryFailed: true }).then(() => {
1164
1264
  const loadedAudio = audioRef.current;
1165
1265
  if (loadedAudio) {
1166
1266
  audioPlaybackManager.stopOthers(stopThis);