@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.
@@ -11,12 +11,16 @@ interface UseAudioBlobLoaderOptions {
11
11
  preloadedBlob?: Blob | null;
12
12
  playbackCallbacks: PlaybackCallbacks;
13
13
  }
14
+ interface EnsureAudioLoadedOptions {
15
+ /** Retry a src that already failed — only on explicit user intent, not on auto-prefetch */
16
+ retryFailed?: boolean;
17
+ }
14
18
  interface UseAudioBlobLoaderResult {
15
19
  audioRef: React.MutableRefObject<HTMLAudioElement | null>;
16
20
  isLoading: boolean;
17
21
  isLoaded: boolean;
18
22
  waveformLevels: number[];
19
- ensureAudioLoaded: () => Promise<void>;
23
+ ensureAudioLoaded: (options?: EnsureAudioLoadedOptions) => Promise<void>;
20
24
  }
21
25
  declare const useAudioBlobLoader: (options: UseAudioBlobLoaderOptions) => UseAudioBlobLoaderResult;
22
26
  export { useAudioBlobLoader };
@@ -1 +1 @@
1
- {"version":3,"file":"useAudioBlobLoader.d.ts","sourceRoot":"","sources":["../../src/hooks/useAudioBlobLoader.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAMH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAYrD,UAAU,yBAAyB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,aAAa,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IAC5B,iBAAiB,EAAE,iBAAiB,CAAC;CACxC;AAED,UAAU,wBAAwB;IAC9B,QAAQ,EAAE,KAAK,CAAC,gBAAgB,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;IAC1D,SAAS,EAAE,OAAO,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;IAClB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,iBAAiB,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1C;AAED,QAAA,MAAM,kBAAkB,GAAI,SAAS,yBAAyB,KAAG,wBA2IhE,CAAC;AAGF,OAAO,EAAE,kBAAkB,EAAE,CAAC"}
1
+ {"version":3,"file":"useAudioBlobLoader.d.ts","sourceRoot":"","sources":["../../src/hooks/useAudioBlobLoader.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAMH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAYrD,UAAU,yBAAyB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,aAAa,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IAC5B,iBAAiB,EAAE,iBAAiB,CAAC;CACxC;AAED,UAAU,wBAAwB;IAC9B,2FAA2F;IAC3F,WAAW,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,UAAU,wBAAwB;IAC9B,QAAQ,EAAE,KAAK,CAAC,gBAAgB,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;IAC1D,SAAS,EAAE,OAAO,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;IAClB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,iBAAiB,EAAE,CAAC,OAAO,CAAC,EAAE,wBAAwB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5E;AAED,QAAA,MAAM,kBAAkB,GAAI,SAAS,yBAAyB,KAAG,wBA4KhE,CAAC;AAGF,OAAO,EAAE,kBAAkB,EAAE,CAAC"}
package/dist/index.cjs CHANGED
@@ -191,6 +191,64 @@ const TIMELINE_BAR_GAP = 2;
191
191
  const TIMELINE_BAR_MIN_HEIGHT = 3;
192
192
  const TIMELINE_BAR_MAX_HEIGHT = 24;
193
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
+
194
252
  /**
195
253
  * Audio analyzer platform adapter
196
254
  *
@@ -217,11 +275,24 @@ const readFrequencyLevels = (analyser, barCount = AUDIO_LEVEL_BARS) => {
217
275
  }
218
276
  return levels;
219
277
  };
278
+ /** decodeAudioData detaches the buffer, so the retry re-reads the blob */
279
+ const decodeSkippingJunk = async (context, blob) => {
280
+ try {
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;
288
+ }
289
+ return context.decodeAudioData(bytes.buffer.slice(offset));
290
+ }
291
+ };
220
292
  const decodeAudioLevels = async (blob, barCount = AUDIO_LEVEL_BARS) => {
221
293
  const context = new AudioContext();
222
294
  try {
223
- const arrayBuffer = await blob.arrayBuffer();
224
- const audioBuffer = await context.decodeAudioData(arrayBuffer);
295
+ const audioBuffer = await decodeSkippingJunk(context, blob);
225
296
  const channelData = audioBuffer.getChannelData(0);
226
297
  if (!channelData.length) {
227
298
  return Array.from({ length: barCount }, () => 0);
@@ -258,6 +329,9 @@ const fetchAudioBlob = async (url, signal) => {
258
329
  mode: 'cors',
259
330
  credentials: 'omit',
260
331
  });
332
+ if (!response.ok) {
333
+ throw new Error(`Failed to fetch audio: ${response.status}`);
334
+ }
261
335
  return response.blob();
262
336
  };
263
337
 
@@ -890,7 +964,10 @@ const useAudioBlobLoader = (options) => {
890
964
  const objectUrlRef = React.useRef(null);
891
965
  const abortControllerRef = React.useRef(null);
892
966
  const failedSrcRef = React.useRef(null);
967
+ const waveformTokenRef = React.useRef(0);
968
+ const waveformDataRef = React.useRef(waveformData);
893
969
  const callbacksRef = React.useRef(playbackCallbacks);
970
+ waveformDataRef.current = waveformData;
894
971
  callbacksRef.current = playbackCallbacks;
895
972
  const createAudioFromBlob = React.useCallback((blob) => {
896
973
  if (objectUrlRef.current) {
@@ -913,15 +990,33 @@ const useAudioBlobLoader = (options) => {
913
990
  audioRef.current = audio;
914
991
  setIsLoaded(true);
915
992
  }, []);
993
+ /**
994
+ * Waveform is cosmetic: some files (e.g. mp3 with junk before the first
995
+ * frame) fail to decode in Safari while playing fine, so a decode error
996
+ * must never block playback. Token keeps only the latest decode.
997
+ */
998
+ const applyWaveformLevels = React.useCallback(async (blob) => {
999
+ if (waveformData) {
1000
+ return;
1001
+ }
1002
+ waveformTokenRef.current += 1;
1003
+ const token = waveformTokenRef.current;
1004
+ try {
1005
+ const levels = await decodeAudioLevels(blob);
1006
+ if (token === waveformTokenRef.current) {
1007
+ setWaveformLevels(levels);
1008
+ }
1009
+ }
1010
+ catch (error) {
1011
+ console.warn('[voice-player] Failed to decode waveform', error);
1012
+ }
1013
+ }, [waveformData]);
916
1014
  const mountFromBlob = React.useCallback(async (blob) => {
917
1015
  if (audioRef.current) {
918
1016
  return;
919
1017
  }
920
1018
  setIsLoading(true);
921
1019
  try {
922
- if (!waveformData) {
923
- setWaveformLevels(await decodeAudioLevels(blob));
924
- }
925
1020
  createAudioFromBlob(blob);
926
1021
  }
927
1022
  catch (error) {
@@ -930,12 +1025,19 @@ const useAudioBlobLoader = (options) => {
930
1025
  finally {
931
1026
  setIsLoading(false);
932
1027
  }
933
- }, [waveformData, createAudioFromBlob]);
934
- const ensureAudioLoaded = React.useCallback(async () => {
1028
+ void applyWaveformLevels(blob);
1029
+ }, [applyWaveformLevels, createAudioFromBlob]);
1030
+ const ensureAudioLoaded = React.useCallback(async (options) => {
935
1031
  var _a;
936
- if (audioRef.current || failedSrcRef.current === src) {
1032
+ if (audioRef.current) {
937
1033
  return;
938
1034
  }
1035
+ if (failedSrcRef.current === src) {
1036
+ if (!(options === null || options === void 0 ? void 0 : options.retryFailed)) {
1037
+ return;
1038
+ }
1039
+ failedSrcRef.current = null;
1040
+ }
939
1041
  if (preloadedBlob) {
940
1042
  return mountFromBlob(preloadedBlob);
941
1043
  }
@@ -951,14 +1053,8 @@ const useAudioBlobLoader = (options) => {
951
1053
  if (controller.signal.aborted) {
952
1054
  return;
953
1055
  }
954
- if (!waveformData) {
955
- const levels = await decodeAudioLevels(blob);
956
- if (controller.signal.aborted) {
957
- return;
958
- }
959
- setWaveformLevels(levels);
960
- }
961
1056
  createAudioFromBlob(blob);
1057
+ void applyWaveformLevels(blob);
962
1058
  }
963
1059
  catch (error) {
964
1060
  if (error instanceof DOMException && error.name === 'AbortError') {
@@ -972,13 +1068,16 @@ const useAudioBlobLoader = (options) => {
972
1068
  setIsLoading(false);
973
1069
  }
974
1070
  }
975
- }, [src, waveformData, preloadedBlob, mountFromBlob, createAudioFromBlob]);
1071
+ }, [src, preloadedBlob, mountFromBlob, applyWaveformLevels, createAudioFromBlob]);
976
1072
  /** Cleanup on src change or unmount */
977
1073
  React.useEffect(() => {
978
1074
  failedSrcRef.current = null;
1075
+ /** Drop the previous waveform so a failed decode cannot leave a foreign one */
1076
+ setWaveformLevels(waveformDataRef.current || createInitialLevels());
979
1077
  return () => {
980
1078
  var _a;
981
1079
  (_a = abortControllerRef.current) === null || _a === void 0 ? void 0 : _a.abort();
1080
+ waveformTokenRef.current += 1;
982
1081
  if (audioRef.current) {
983
1082
  cleanupAudio(audioRef.current);
984
1083
  audioRef.current = null;
@@ -997,6 +1096,7 @@ const useAudioBlobLoader = (options) => {
997
1096
  }, [preloadedBlob, mountFromBlob]);
998
1097
  React.useEffect(() => {
999
1098
  if (waveformData) {
1099
+ waveformTokenRef.current += 1;
1000
1100
  setWaveformLevels(waveformData);
1001
1101
  }
1002
1102
  }, [waveformData]);
@@ -1102,7 +1202,7 @@ const useVoiceMessagePlayer = (options) => {
1102
1202
  return;
1103
1203
  }
1104
1204
  audioPlaybackManager.stopOthers(stopThis);
1105
- void ensureAudioLoaded().then(() => {
1205
+ void ensureAudioLoaded({ retryFailed: true }).then(() => {
1106
1206
  const audio = audioRef.current;
1107
1207
  if (audio) {
1108
1208
  audio.play()
@@ -1162,7 +1262,7 @@ const useVoiceMessagePlayer = (options) => {
1162
1262
  .catch(() => setIsPlaying(false));
1163
1263
  }
1164
1264
  else {
1165
- void ensureAudioLoaded().then(() => {
1265
+ void ensureAudioLoaded({ retryFailed: true }).then(() => {
1166
1266
  const loadedAudio = audioRef.current;
1167
1267
  if (loadedAudio) {
1168
1268
  audioPlaybackManager.stopOthers(stopThis);