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