@exode-team/react-recorder 1.1.11 → 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/README.md CHANGED
@@ -146,7 +146,7 @@ const Messages = () => {
146
146
  | `duration` | `number` | — | — | Длительность в секундах (отображается до загрузки аудио). |
147
147
  | `autoPreload` | `boolean` | — | `true` | Автозагрузка аудио при попадании в viewport (IntersectionObserver). |
148
148
  | `waveformData` | `number[]` | — | — | Предварительно вычисленные уровни waveform. |
149
- | `preloadedBlob` | `Blob \| null` | — | — | Blob для мгновенного воспроизведения без сетевого запроса. |
149
+ | `preloadedBlob` | `Blob \| null` | — | — | Локальная запись. Играет из него, только пока нет `src`; при заданном `src` идёт на него, а blob переиспользуется для waveform. |
150
150
  | `onEnded` | `() => void` | — | — | Колбэк окончания воспроизведения. |
151
151
  | `className` | `string` | — | — | CSS-класс контейнера. |
152
152
  | `onClick` | `(e: React.MouseEvent) => void`| — | — | Обработчик клика по контейнеру. |
@@ -154,7 +154,7 @@ const Messages = () => {
154
154
  **Ref API (`VoiceMessagePlayerRef`):**
155
155
 
156
156
  - `play()` — программный запуск воспроизведения.
157
- - `prefetch()` — загрузка аудио без воспроизведения (fetch + decode waveform).
157
+ - `prefetch()` — подготовка аудио без воспроизведения (waveform считается фоном и play не задерживает).
158
158
 
159
159
  #### `VoicePlaybackChain`
160
160
 
@@ -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,16 +11,12 @@ 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
- }
18
14
  interface UseAudioBlobLoaderResult {
19
15
  audioRef: React.MutableRefObject<HTMLAudioElement | null>;
20
16
  isLoading: boolean;
21
17
  isLoaded: boolean;
22
18
  waveformLevels: number[];
23
- ensureAudioLoaded: (options?: EnsureAudioLoadedOptions) => Promise<void>;
19
+ ensureAudioLoaded: () => Promise<void>;
24
20
  }
25
21
  declare const useAudioBlobLoader: (options: UseAudioBlobLoaderOptions) => UseAudioBlobLoaderResult;
26
22
  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,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"}
1
+ {"version":3,"file":"useAudioBlobLoader.d.ts","sourceRoot":"","sources":["../../src/hooks/useAudioBlobLoader.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,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,wBAyLhE,CAAC;AAGF,OAAO,EAAE,kBAAkB,EAAE,CAAC"}
package/dist/index.cjs CHANGED
@@ -30,6 +30,56 @@ const formatTime = (seconds) => {
30
30
  return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
31
31
  };
32
32
 
33
+ /**
34
+ * AudioRecorder constants
35
+ *
36
+ * @author: exode <hello@exode.ru>
37
+ */
38
+ const AUDIO_LEVEL_BARS = 40;
39
+ /** Waveform decoding resamples to this rate — only the amplitude envelope matters */
40
+ const WAVEFORM_SAMPLE_RATE = 44100;
41
+ const createInitialLevels = () => Array.from({ length: AUDIO_LEVEL_BARS }, () => 0);
42
+ const TIMELINE_SAMPLE_INTERVAL = 150;
43
+ const TIMELINE_BAR_WIDTH = 3;
44
+ const TIMELINE_BAR_GAP = 2;
45
+ const TIMELINE_BAR_MIN_HEIGHT = 3;
46
+ const TIMELINE_BAR_MAX_HEIGHT = 24;
47
+
48
+ /**
49
+ * Pseudo waveform utilities
50
+ *
51
+ * @author: exode <hello@exode.ru>
52
+ */
53
+ /** Deterministic generator: the same source always draws the same waveform */
54
+ const seededRandom = (seed) => {
55
+ let state = Math.abs(Math.floor(seed)) % 2147483647 || 1;
56
+ return () => (state = state * 16807 % 2147483647) / 2147483647;
57
+ };
58
+ const hashString = (value) => {
59
+ let hash = 0;
60
+ for (let i = 0; i < value.length; i += 1) {
61
+ hash = (hash * 31 + value.charCodeAt(i)) | 0;
62
+ }
63
+ return hash;
64
+ };
65
+ /**
66
+ * Decorative levels for runtimes that cannot decode audio at all — WebAudio
67
+ * rejects every mp3 inside iPad apps running on macOS. Purely cosmetic: it
68
+ * stands in for a flat line and says nothing about the recording itself.
69
+ */
70
+ const createPseudoLevels = (seed, barCount = AUDIO_LEVEL_BARS) => {
71
+ const random = seededRandom(hashString(seed));
72
+ const levels = [];
73
+ let level = 45 + random() * 25;
74
+ for (let i = 0; i < barCount; i += 1) {
75
+ /** Smooth drift instead of pure noise, so it reads as speech rather than static */
76
+ level = clampValue(level + (random() - 0.5) * 42, 14);
77
+ /** Slight fade at both ends — that is how a real recording usually looks */
78
+ levels.push(level * (0.72 + 0.28 * Math.sin((i / (barCount - 1)) * Math.PI)));
79
+ }
80
+ return levels;
81
+ };
82
+
33
83
  /**
34
84
  * Microphone platform adapter
35
85
  *
@@ -135,6 +185,12 @@ const clearMediaSession = () => {
135
185
  };
136
186
  const createAudioPlayback = (url, callbacks) => {
137
187
  const audio = new Audio(url);
188
+ /**
189
+ * A detached element is not reliably rendered by every WebKit runtime
190
+ * (notably iPad apps running on macOS), so keep it in the document.
191
+ */
192
+ audio.style.display = 'none';
193
+ document.body.appendChild(audio);
138
194
  audio.onloadedmetadata = () => {
139
195
  callbacks.onLoadedMetadata(audio.duration);
140
196
  };
@@ -163,6 +219,7 @@ const createAudioPlayback = (url, callbacks) => {
163
219
  };
164
220
  const cleanupAudio = (audio) => {
165
221
  audio.pause();
222
+ audio.remove();
166
223
  audio.src = '';
167
224
  audio.onended = null;
168
225
  audio.ontimeupdate = null;
@@ -178,19 +235,6 @@ const revokeObjectURL = (url) => {
178
235
  URL.revokeObjectURL(url);
179
236
  };
180
237
 
181
- /**
182
- * AudioRecorder constants
183
- *
184
- * @author: exode <hello@exode.ru>
185
- */
186
- const AUDIO_LEVEL_BARS = 40;
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
238
  /**
195
239
  * MPEG frame scanner
196
240
  *
@@ -290,32 +334,32 @@ const decodeSkippingJunk = async (context, blob) => {
290
334
  }
291
335
  };
292
336
  const decodeAudioLevels = async (blob, barCount = AUDIO_LEVEL_BARS) => {
293
- const context = new AudioContext();
294
- try {
295
- const audioBuffer = await decodeSkippingJunk(context, blob);
296
- const channelData = audioBuffer.getChannelData(0);
297
- if (!channelData.length) {
298
- return Array.from({ length: barCount }, () => 0);
299
- }
300
- const levels = [];
301
- const segmentLength = Math.max(1, Math.floor(channelData.length / barCount));
302
- for (let i = 0; i < barCount; i += 1) {
303
- const segmentStart = i * segmentLength;
304
- let sum = 0;
305
- for (let j = 0; j < segmentLength; j += 1) {
306
- const sample = channelData[segmentStart + j];
307
- if (sample !== undefined) {
308
- sum += Math.abs(sample);
309
- }
337
+ /**
338
+ * Offline rendering keeps the audio hardware untouched. Runtimes that
339
+ * cannot decode at all (iPad apps on macOS reject every mp3) fall through
340
+ * to the caller, which draws a placeholder waveform instead.
341
+ */
342
+ const context = new OfflineAudioContext(1, 1, WAVEFORM_SAMPLE_RATE);
343
+ const audioBuffer = await decodeSkippingJunk(context, blob);
344
+ const channelData = audioBuffer.getChannelData(0);
345
+ if (!channelData.length) {
346
+ return Array.from({ length: barCount }, () => 0);
347
+ }
348
+ const levels = [];
349
+ const segmentLength = Math.max(1, Math.floor(channelData.length / barCount));
350
+ for (let i = 0; i < barCount; i += 1) {
351
+ const segmentStart = i * segmentLength;
352
+ let sum = 0;
353
+ for (let j = 0; j < segmentLength; j += 1) {
354
+ const sample = channelData[segmentStart + j];
355
+ if (sample !== undefined) {
356
+ sum += Math.abs(sample);
310
357
  }
311
- const average = sum / segmentLength;
312
- levels.push(Math.min(100, average * 200));
313
358
  }
314
- return levels;
315
- }
316
- finally {
317
- await context.close();
359
+ const average = sum / segmentLength;
360
+ levels.push(Math.min(100, average * 200));
318
361
  }
362
+ return levels;
319
363
  };
320
364
 
321
365
  /**
@@ -963,18 +1007,12 @@ const useAudioBlobLoader = (options) => {
963
1007
  const audioRef = React.useRef(null);
964
1008
  const objectUrlRef = React.useRef(null);
965
1009
  const abortControllerRef = React.useRef(null);
966
- const failedSrcRef = React.useRef(null);
967
1010
  const waveformTokenRef = React.useRef(0);
968
1011
  const waveformDataRef = React.useRef(waveformData);
969
1012
  const callbacksRef = React.useRef(playbackCallbacks);
970
1013
  waveformDataRef.current = waveformData;
971
1014
  callbacksRef.current = playbackCallbacks;
972
- const createAudioFromBlob = React.useCallback((blob) => {
973
- if (objectUrlRef.current) {
974
- revokeObjectURL(objectUrlRef.current);
975
- }
976
- const url = createObjectURL(blob);
977
- objectUrlRef.current = url;
1015
+ const createAudioFromUrl = React.useCallback((url) => {
978
1016
  const { audio } = createAudioPlayback(url, {
979
1017
  onLoadedMetadata: (dur) => callbacksRef.current.onLoadedMetadata(dur),
980
1018
  onTimeUpdate: (time) => callbacksRef.current.onTimeUpdate(time),
@@ -990,13 +1028,22 @@ const useAudioBlobLoader = (options) => {
990
1028
  audioRef.current = audio;
991
1029
  setIsLoaded(true);
992
1030
  }, []);
1031
+ /** Local recordings have no url of their own, so they still go through a blob */
1032
+ const createAudioFromBlob = React.useCallback((blob) => {
1033
+ if (objectUrlRef.current) {
1034
+ revokeObjectURL(objectUrlRef.current);
1035
+ }
1036
+ objectUrlRef.current = createObjectURL(blob);
1037
+ createAudioFromUrl(objectUrlRef.current);
1038
+ }, [createAudioFromUrl]);
993
1039
  /**
994
1040
  * Waveform is cosmetic: some files (e.g. mp3 with junk before the first
995
1041
  * frame) fail to decode in Safari while playing fine, so a decode error
996
1042
  * must never block playback. Token keeps only the latest decode.
997
1043
  */
998
- const applyWaveformLevels = React.useCallback(async (blob) => {
999
- if (waveformData) {
1044
+ const applyWaveformLevels = React.useCallback(async (blob, seed) => {
1045
+ /** Read through the ref: levels may arrive while the bytes are still loading */
1046
+ if (waveformDataRef.current) {
1000
1047
  return;
1001
1048
  }
1002
1049
  waveformTokenRef.current += 1;
@@ -1009,8 +1056,12 @@ const useAudioBlobLoader = (options) => {
1009
1056
  }
1010
1057
  catch (error) {
1011
1058
  console.warn('[voice-player] Failed to decode waveform', error);
1059
+ /** Some runtimes decode nothing at all — draw a placeholder instead of a flat line */
1060
+ if (token === waveformTokenRef.current) {
1061
+ setWaveformLevels(createPseudoLevels(seed));
1062
+ }
1012
1063
  }
1013
- }, [waveformData]);
1064
+ }, []);
1014
1065
  const mountFromBlob = React.useCallback(async (blob) => {
1015
1066
  if (audioRef.current) {
1016
1067
  return;
@@ -1025,53 +1076,51 @@ const useAudioBlobLoader = (options) => {
1025
1076
  finally {
1026
1077
  setIsLoading(false);
1027
1078
  }
1028
- void applyWaveformLevels(blob);
1079
+ void applyWaveformLevels(blob, `${blob.size}`);
1029
1080
  }, [applyWaveformLevels, createAudioFromBlob]);
1030
- const ensureAudioLoaded = React.useCallback(async (options) => {
1081
+ /** Waveform bytes are cosmetic, so nothing here may delay playback */
1082
+ const loadWaveformSource = React.useCallback(async (url, cached) => {
1031
1083
  var _a;
1032
- if (audioRef.current) {
1084
+ if (waveformDataRef.current) {
1033
1085
  return;
1034
1086
  }
1035
- if (failedSrcRef.current === src) {
1036
- if (!(options === null || options === void 0 ? void 0 : options.retryFailed)) {
1037
- return;
1038
- }
1039
- failedSrcRef.current = null;
1040
- }
1041
- if (preloadedBlob) {
1042
- return mountFromBlob(preloadedBlob);
1043
- }
1044
- if (!src) {
1045
- return;
1087
+ if (cached) {
1088
+ return applyWaveformLevels(cached, url);
1046
1089
  }
1047
1090
  (_a = abortControllerRef.current) === null || _a === void 0 ? void 0 : _a.abort();
1048
1091
  const controller = new AbortController();
1049
1092
  abortControllerRef.current = controller;
1050
- setIsLoading(true);
1051
1093
  try {
1052
- const blob = await fetchAudioBlob(src, controller.signal);
1053
- if (controller.signal.aborted) {
1054
- return;
1094
+ const blob = await fetchAudioBlob(url, controller.signal);
1095
+ if (!controller.signal.aborted) {
1096
+ await applyWaveformLevels(blob, url);
1055
1097
  }
1056
- createAudioFromBlob(blob);
1057
- void applyWaveformLevels(blob);
1058
1098
  }
1059
1099
  catch (error) {
1060
1100
  if (error instanceof DOMException && error.name === 'AbortError') {
1061
1101
  return;
1062
1102
  }
1063
- failedSrcRef.current = src;
1064
- console.warn('[voice-player] Failed to load audio', error);
1103
+ console.warn('[voice-player] Failed to fetch waveform source', error);
1065
1104
  }
1066
- finally {
1067
- if (!controller.signal.aborted) {
1068
- setIsLoading(false);
1069
- }
1105
+ }, [applyWaveformLevels]);
1106
+ const ensureAudioLoaded = React.useCallback(async () => {
1107
+ if (audioRef.current) {
1108
+ return;
1070
1109
  }
1071
- }, [src, preloadedBlob, mountFromBlob, applyWaveformLevels, createAudioFromBlob]);
1110
+ /** Only a recording that has not been uploaded yet has no url of its own */
1111
+ if (!src) {
1112
+ return preloadedBlob ? mountFromBlob(preloadedBlob) : undefined;
1113
+ }
1114
+ /**
1115
+ * Remote files play straight from their url: it streams with range
1116
+ * requests, keeps the user gesture (no await before play) and avoids
1117
+ * blob sources, which some WebKit runtimes refuse to render.
1118
+ */
1119
+ createAudioFromUrl(src);
1120
+ void loadWaveformSource(src, preloadedBlob);
1121
+ }, [src, preloadedBlob, mountFromBlob, loadWaveformSource, createAudioFromUrl]);
1072
1122
  /** Cleanup on src change or unmount */
1073
1123
  React.useEffect(() => {
1074
- failedSrcRef.current = null;
1075
1124
  /** Drop the previous waveform so a failed decode cannot leave a foreign one */
1076
1125
  setWaveformLevels(waveformDataRef.current || createInitialLevels());
1077
1126
  return () => {
@@ -1089,11 +1138,12 @@ const useAudioBlobLoader = (options) => {
1089
1138
  setIsLoaded(false);
1090
1139
  };
1091
1140
  }, [src]);
1141
+ /** A cached recording mounts on its own only while it has no url yet */
1092
1142
  React.useEffect(() => {
1093
- if (preloadedBlob && !audioRef.current) {
1143
+ if (preloadedBlob && !src && !audioRef.current) {
1094
1144
  void mountFromBlob(preloadedBlob);
1095
1145
  }
1096
- }, [preloadedBlob, mountFromBlob]);
1146
+ }, [src, preloadedBlob, mountFromBlob]);
1097
1147
  React.useEffect(() => {
1098
1148
  if (waveformData) {
1099
1149
  waveformTokenRef.current += 1;
@@ -1202,7 +1252,7 @@ const useVoiceMessagePlayer = (options) => {
1202
1252
  return;
1203
1253
  }
1204
1254
  audioPlaybackManager.stopOthers(stopThis);
1205
- void ensureAudioLoaded({ retryFailed: true }).then(() => {
1255
+ void ensureAudioLoaded().then(() => {
1206
1256
  const audio = audioRef.current;
1207
1257
  if (audio) {
1208
1258
  audio.play()
@@ -1262,7 +1312,7 @@ const useVoiceMessagePlayer = (options) => {
1262
1312
  .catch(() => setIsPlaying(false));
1263
1313
  }
1264
1314
  else {
1265
- void ensureAudioLoaded({ retryFailed: true }).then(() => {
1315
+ void ensureAudioLoaded().then(() => {
1266
1316
  const loadedAudio = audioRef.current;
1267
1317
  if (loadedAudio) {
1268
1318
  audioPlaybackManager.stopOthers(stopThis);