@exode-team/react-recorder 1.1.10 → 1.1.12

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