@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 +2 -2
- package/dist/constants.d.ts +2 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/hooks/useAudioBlobLoader.d.ts +1 -5
- package/dist/hooks/useAudioBlobLoader.d.ts.map +1 -1
- package/dist/index.cjs +128 -78
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +128 -78
- package/dist/index.js.map +1 -1
- package/dist/platform/audio-analyzer-adapter.d.ts.map +1 -1
- package/dist/platform/audio-playback-adapter.d.ts.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/pseudo-waveform.d.ts +12 -0
- package/dist/utils/pseudo-waveform.d.ts.map +1 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -28,6 +28,56 @@ const formatTime = (seconds) => {
|
|
|
28
28
|
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* AudioRecorder constants
|
|
33
|
+
*
|
|
34
|
+
* @author: exode <hello@exode.ru>
|
|
35
|
+
*/
|
|
36
|
+
const AUDIO_LEVEL_BARS = 40;
|
|
37
|
+
/** Waveform decoding resamples to this rate — only the amplitude envelope matters */
|
|
38
|
+
const WAVEFORM_SAMPLE_RATE = 44100;
|
|
39
|
+
const createInitialLevels = () => Array.from({ length: AUDIO_LEVEL_BARS }, () => 0);
|
|
40
|
+
const TIMELINE_SAMPLE_INTERVAL = 150;
|
|
41
|
+
const TIMELINE_BAR_WIDTH = 3;
|
|
42
|
+
const TIMELINE_BAR_GAP = 2;
|
|
43
|
+
const TIMELINE_BAR_MIN_HEIGHT = 3;
|
|
44
|
+
const TIMELINE_BAR_MAX_HEIGHT = 24;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Pseudo waveform utilities
|
|
48
|
+
*
|
|
49
|
+
* @author: exode <hello@exode.ru>
|
|
50
|
+
*/
|
|
51
|
+
/** Deterministic generator: the same source always draws the same waveform */
|
|
52
|
+
const seededRandom = (seed) => {
|
|
53
|
+
let state = Math.abs(Math.floor(seed)) % 2147483647 || 1;
|
|
54
|
+
return () => (state = state * 16807 % 2147483647) / 2147483647;
|
|
55
|
+
};
|
|
56
|
+
const hashString = (value) => {
|
|
57
|
+
let hash = 0;
|
|
58
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
59
|
+
hash = (hash * 31 + value.charCodeAt(i)) | 0;
|
|
60
|
+
}
|
|
61
|
+
return hash;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Decorative levels for runtimes that cannot decode audio at all — WebAudio
|
|
65
|
+
* rejects every mp3 inside iPad apps running on macOS. Purely cosmetic: it
|
|
66
|
+
* stands in for a flat line and says nothing about the recording itself.
|
|
67
|
+
*/
|
|
68
|
+
const createPseudoLevels = (seed, barCount = AUDIO_LEVEL_BARS) => {
|
|
69
|
+
const random = seededRandom(hashString(seed));
|
|
70
|
+
const levels = [];
|
|
71
|
+
let level = 45 + random() * 25;
|
|
72
|
+
for (let i = 0; i < barCount; i += 1) {
|
|
73
|
+
/** Smooth drift instead of pure noise, so it reads as speech rather than static */
|
|
74
|
+
level = clampValue(level + (random() - 0.5) * 42, 14);
|
|
75
|
+
/** Slight fade at both ends — that is how a real recording usually looks */
|
|
76
|
+
levels.push(level * (0.72 + 0.28 * Math.sin((i / (barCount - 1)) * Math.PI)));
|
|
77
|
+
}
|
|
78
|
+
return levels;
|
|
79
|
+
};
|
|
80
|
+
|
|
31
81
|
/**
|
|
32
82
|
* Microphone platform adapter
|
|
33
83
|
*
|
|
@@ -133,6 +183,12 @@ const clearMediaSession = () => {
|
|
|
133
183
|
};
|
|
134
184
|
const createAudioPlayback = (url, callbacks) => {
|
|
135
185
|
const audio = new Audio(url);
|
|
186
|
+
/**
|
|
187
|
+
* A detached element is not reliably rendered by every WebKit runtime
|
|
188
|
+
* (notably iPad apps running on macOS), so keep it in the document.
|
|
189
|
+
*/
|
|
190
|
+
audio.style.display = 'none';
|
|
191
|
+
document.body.appendChild(audio);
|
|
136
192
|
audio.onloadedmetadata = () => {
|
|
137
193
|
callbacks.onLoadedMetadata(audio.duration);
|
|
138
194
|
};
|
|
@@ -161,6 +217,7 @@ const createAudioPlayback = (url, callbacks) => {
|
|
|
161
217
|
};
|
|
162
218
|
const cleanupAudio = (audio) => {
|
|
163
219
|
audio.pause();
|
|
220
|
+
audio.remove();
|
|
164
221
|
audio.src = '';
|
|
165
222
|
audio.onended = null;
|
|
166
223
|
audio.ontimeupdate = null;
|
|
@@ -176,19 +233,6 @@ const revokeObjectURL = (url) => {
|
|
|
176
233
|
URL.revokeObjectURL(url);
|
|
177
234
|
};
|
|
178
235
|
|
|
179
|
-
/**
|
|
180
|
-
* AudioRecorder constants
|
|
181
|
-
*
|
|
182
|
-
* @author: exode <hello@exode.ru>
|
|
183
|
-
*/
|
|
184
|
-
const AUDIO_LEVEL_BARS = 40;
|
|
185
|
-
const createInitialLevels = () => Array.from({ length: AUDIO_LEVEL_BARS }, () => 0);
|
|
186
|
-
const TIMELINE_SAMPLE_INTERVAL = 150;
|
|
187
|
-
const TIMELINE_BAR_WIDTH = 3;
|
|
188
|
-
const TIMELINE_BAR_GAP = 2;
|
|
189
|
-
const TIMELINE_BAR_MIN_HEIGHT = 3;
|
|
190
|
-
const TIMELINE_BAR_MAX_HEIGHT = 24;
|
|
191
|
-
|
|
192
236
|
/**
|
|
193
237
|
* MPEG frame scanner
|
|
194
238
|
*
|
|
@@ -288,32 +332,32 @@ const decodeSkippingJunk = async (context, blob) => {
|
|
|
288
332
|
}
|
|
289
333
|
};
|
|
290
334
|
const decodeAudioLevels = async (blob, barCount = AUDIO_LEVEL_BARS) => {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
335
|
+
/**
|
|
336
|
+
* Offline rendering keeps the audio hardware untouched. Runtimes that
|
|
337
|
+
* cannot decode at all (iPad apps on macOS reject every mp3) fall through
|
|
338
|
+
* to the caller, which draws a placeholder waveform instead.
|
|
339
|
+
*/
|
|
340
|
+
const context = new OfflineAudioContext(1, 1, WAVEFORM_SAMPLE_RATE);
|
|
341
|
+
const audioBuffer = await decodeSkippingJunk(context, blob);
|
|
342
|
+
const channelData = audioBuffer.getChannelData(0);
|
|
343
|
+
if (!channelData.length) {
|
|
344
|
+
return Array.from({ length: barCount }, () => 0);
|
|
345
|
+
}
|
|
346
|
+
const levels = [];
|
|
347
|
+
const segmentLength = Math.max(1, Math.floor(channelData.length / barCount));
|
|
348
|
+
for (let i = 0; i < barCount; i += 1) {
|
|
349
|
+
const segmentStart = i * segmentLength;
|
|
350
|
+
let sum = 0;
|
|
351
|
+
for (let j = 0; j < segmentLength; j += 1) {
|
|
352
|
+
const sample = channelData[segmentStart + j];
|
|
353
|
+
if (sample !== undefined) {
|
|
354
|
+
sum += Math.abs(sample);
|
|
308
355
|
}
|
|
309
|
-
const average = sum / segmentLength;
|
|
310
|
-
levels.push(Math.min(100, average * 200));
|
|
311
356
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
finally {
|
|
315
|
-
await context.close();
|
|
357
|
+
const average = sum / segmentLength;
|
|
358
|
+
levels.push(Math.min(100, average * 200));
|
|
316
359
|
}
|
|
360
|
+
return levels;
|
|
317
361
|
};
|
|
318
362
|
|
|
319
363
|
/**
|
|
@@ -961,18 +1005,12 @@ const useAudioBlobLoader = (options) => {
|
|
|
961
1005
|
const audioRef = useRef(null);
|
|
962
1006
|
const objectUrlRef = useRef(null);
|
|
963
1007
|
const abortControllerRef = useRef(null);
|
|
964
|
-
const failedSrcRef = useRef(null);
|
|
965
1008
|
const waveformTokenRef = useRef(0);
|
|
966
1009
|
const waveformDataRef = useRef(waveformData);
|
|
967
1010
|
const callbacksRef = useRef(playbackCallbacks);
|
|
968
1011
|
waveformDataRef.current = waveformData;
|
|
969
1012
|
callbacksRef.current = playbackCallbacks;
|
|
970
|
-
const
|
|
971
|
-
if (objectUrlRef.current) {
|
|
972
|
-
revokeObjectURL(objectUrlRef.current);
|
|
973
|
-
}
|
|
974
|
-
const url = createObjectURL(blob);
|
|
975
|
-
objectUrlRef.current = url;
|
|
1013
|
+
const createAudioFromUrl = useCallback((url) => {
|
|
976
1014
|
const { audio } = createAudioPlayback(url, {
|
|
977
1015
|
onLoadedMetadata: (dur) => callbacksRef.current.onLoadedMetadata(dur),
|
|
978
1016
|
onTimeUpdate: (time) => callbacksRef.current.onTimeUpdate(time),
|
|
@@ -988,13 +1026,22 @@ const useAudioBlobLoader = (options) => {
|
|
|
988
1026
|
audioRef.current = audio;
|
|
989
1027
|
setIsLoaded(true);
|
|
990
1028
|
}, []);
|
|
1029
|
+
/** Local recordings have no url of their own, so they still go through a blob */
|
|
1030
|
+
const createAudioFromBlob = useCallback((blob) => {
|
|
1031
|
+
if (objectUrlRef.current) {
|
|
1032
|
+
revokeObjectURL(objectUrlRef.current);
|
|
1033
|
+
}
|
|
1034
|
+
objectUrlRef.current = createObjectURL(blob);
|
|
1035
|
+
createAudioFromUrl(objectUrlRef.current);
|
|
1036
|
+
}, [createAudioFromUrl]);
|
|
991
1037
|
/**
|
|
992
1038
|
* Waveform is cosmetic: some files (e.g. mp3 with junk before the first
|
|
993
1039
|
* frame) fail to decode in Safari while playing fine, so a decode error
|
|
994
1040
|
* must never block playback. Token keeps only the latest decode.
|
|
995
1041
|
*/
|
|
996
|
-
const applyWaveformLevels = useCallback(async (blob) => {
|
|
997
|
-
|
|
1042
|
+
const applyWaveformLevels = useCallback(async (blob, seed) => {
|
|
1043
|
+
/** Read through the ref: levels may arrive while the bytes are still loading */
|
|
1044
|
+
if (waveformDataRef.current) {
|
|
998
1045
|
return;
|
|
999
1046
|
}
|
|
1000
1047
|
waveformTokenRef.current += 1;
|
|
@@ -1007,8 +1054,12 @@ const useAudioBlobLoader = (options) => {
|
|
|
1007
1054
|
}
|
|
1008
1055
|
catch (error) {
|
|
1009
1056
|
console.warn('[voice-player] Failed to decode waveform', error);
|
|
1057
|
+
/** Some runtimes decode nothing at all — draw a placeholder instead of a flat line */
|
|
1058
|
+
if (token === waveformTokenRef.current) {
|
|
1059
|
+
setWaveformLevels(createPseudoLevels(seed));
|
|
1060
|
+
}
|
|
1010
1061
|
}
|
|
1011
|
-
}, [
|
|
1062
|
+
}, []);
|
|
1012
1063
|
const mountFromBlob = useCallback(async (blob) => {
|
|
1013
1064
|
if (audioRef.current) {
|
|
1014
1065
|
return;
|
|
@@ -1023,53 +1074,51 @@ const useAudioBlobLoader = (options) => {
|
|
|
1023
1074
|
finally {
|
|
1024
1075
|
setIsLoading(false);
|
|
1025
1076
|
}
|
|
1026
|
-
void applyWaveformLevels(blob);
|
|
1077
|
+
void applyWaveformLevels(blob, `${blob.size}`);
|
|
1027
1078
|
}, [applyWaveformLevels, createAudioFromBlob]);
|
|
1028
|
-
|
|
1079
|
+
/** Waveform bytes are cosmetic, so nothing here may delay playback */
|
|
1080
|
+
const loadWaveformSource = useCallback(async (url, cached) => {
|
|
1029
1081
|
var _a;
|
|
1030
|
-
if (
|
|
1082
|
+
if (waveformDataRef.current) {
|
|
1031
1083
|
return;
|
|
1032
1084
|
}
|
|
1033
|
-
if (
|
|
1034
|
-
|
|
1035
|
-
return;
|
|
1036
|
-
}
|
|
1037
|
-
failedSrcRef.current = null;
|
|
1038
|
-
}
|
|
1039
|
-
if (preloadedBlob) {
|
|
1040
|
-
return mountFromBlob(preloadedBlob);
|
|
1041
|
-
}
|
|
1042
|
-
if (!src) {
|
|
1043
|
-
return;
|
|
1085
|
+
if (cached) {
|
|
1086
|
+
return applyWaveformLevels(cached, url);
|
|
1044
1087
|
}
|
|
1045
1088
|
(_a = abortControllerRef.current) === null || _a === void 0 ? void 0 : _a.abort();
|
|
1046
1089
|
const controller = new AbortController();
|
|
1047
1090
|
abortControllerRef.current = controller;
|
|
1048
|
-
setIsLoading(true);
|
|
1049
1091
|
try {
|
|
1050
|
-
const blob = await fetchAudioBlob(
|
|
1051
|
-
if (controller.signal.aborted) {
|
|
1052
|
-
|
|
1092
|
+
const blob = await fetchAudioBlob(url, controller.signal);
|
|
1093
|
+
if (!controller.signal.aborted) {
|
|
1094
|
+
await applyWaveformLevels(blob, url);
|
|
1053
1095
|
}
|
|
1054
|
-
createAudioFromBlob(blob);
|
|
1055
|
-
void applyWaveformLevels(blob);
|
|
1056
1096
|
}
|
|
1057
1097
|
catch (error) {
|
|
1058
1098
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
1059
1099
|
return;
|
|
1060
1100
|
}
|
|
1061
|
-
|
|
1062
|
-
console.warn('[voice-player] Failed to load audio', error);
|
|
1101
|
+
console.warn('[voice-player] Failed to fetch waveform source', error);
|
|
1063
1102
|
}
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1103
|
+
}, [applyWaveformLevels]);
|
|
1104
|
+
const ensureAudioLoaded = useCallback(async () => {
|
|
1105
|
+
if (audioRef.current) {
|
|
1106
|
+
return;
|
|
1068
1107
|
}
|
|
1069
|
-
|
|
1108
|
+
/** Only a recording that has not been uploaded yet has no url of its own */
|
|
1109
|
+
if (!src) {
|
|
1110
|
+
return preloadedBlob ? mountFromBlob(preloadedBlob) : undefined;
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Remote files play straight from their url: it streams with range
|
|
1114
|
+
* requests, keeps the user gesture (no await before play) and avoids
|
|
1115
|
+
* blob sources, which some WebKit runtimes refuse to render.
|
|
1116
|
+
*/
|
|
1117
|
+
createAudioFromUrl(src);
|
|
1118
|
+
void loadWaveformSource(src, preloadedBlob);
|
|
1119
|
+
}, [src, preloadedBlob, mountFromBlob, loadWaveformSource, createAudioFromUrl]);
|
|
1070
1120
|
/** Cleanup on src change or unmount */
|
|
1071
1121
|
useEffect(() => {
|
|
1072
|
-
failedSrcRef.current = null;
|
|
1073
1122
|
/** Drop the previous waveform so a failed decode cannot leave a foreign one */
|
|
1074
1123
|
setWaveformLevels(waveformDataRef.current || createInitialLevels());
|
|
1075
1124
|
return () => {
|
|
@@ -1087,11 +1136,12 @@ const useAudioBlobLoader = (options) => {
|
|
|
1087
1136
|
setIsLoaded(false);
|
|
1088
1137
|
};
|
|
1089
1138
|
}, [src]);
|
|
1139
|
+
/** A cached recording mounts on its own only while it has no url yet */
|
|
1090
1140
|
useEffect(() => {
|
|
1091
|
-
if (preloadedBlob && !audioRef.current) {
|
|
1141
|
+
if (preloadedBlob && !src && !audioRef.current) {
|
|
1092
1142
|
void mountFromBlob(preloadedBlob);
|
|
1093
1143
|
}
|
|
1094
|
-
}, [preloadedBlob, mountFromBlob]);
|
|
1144
|
+
}, [src, preloadedBlob, mountFromBlob]);
|
|
1095
1145
|
useEffect(() => {
|
|
1096
1146
|
if (waveformData) {
|
|
1097
1147
|
waveformTokenRef.current += 1;
|
|
@@ -1200,7 +1250,7 @@ const useVoiceMessagePlayer = (options) => {
|
|
|
1200
1250
|
return;
|
|
1201
1251
|
}
|
|
1202
1252
|
audioPlaybackManager.stopOthers(stopThis);
|
|
1203
|
-
void ensureAudioLoaded(
|
|
1253
|
+
void ensureAudioLoaded().then(() => {
|
|
1204
1254
|
const audio = audioRef.current;
|
|
1205
1255
|
if (audio) {
|
|
1206
1256
|
audio.play()
|
|
@@ -1260,7 +1310,7 @@ const useVoiceMessagePlayer = (options) => {
|
|
|
1260
1310
|
.catch(() => setIsPlaying(false));
|
|
1261
1311
|
}
|
|
1262
1312
|
else {
|
|
1263
|
-
void ensureAudioLoaded(
|
|
1313
|
+
void ensureAudioLoaded().then(() => {
|
|
1264
1314
|
const loadedAudio = audioRef.current;
|
|
1265
1315
|
if (loadedAudio) {
|
|
1266
1316
|
audioPlaybackManager.stopOthers(stopThis);
|