@exode-team/react-recorder 1.1.12 → 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/hooks/useAudioBlobLoader.d.ts +1 -5
- package/dist/hooks/useAudioBlobLoader.d.ts.map +1 -1
- package/dist/index.cjs +108 -61
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +108 -61
- 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,21 +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
|
-
/** Waveform decoding resamples to this rate — only the amplitude envelope matters */
|
|
186
|
-
const WAVEFORM_SAMPLE_RATE = 44100;
|
|
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
236
|
/**
|
|
195
237
|
* MPEG frame scanner
|
|
196
238
|
*
|
|
@@ -291,10 +333,9 @@ const decodeSkippingJunk = async (context, blob) => {
|
|
|
291
333
|
};
|
|
292
334
|
const decodeAudioLevels = async (blob, barCount = AUDIO_LEVEL_BARS) => {
|
|
293
335
|
/**
|
|
294
|
-
* Offline
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
* iPad apps running on macOS.
|
|
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.
|
|
298
339
|
*/
|
|
299
340
|
const context = new OfflineAudioContext(1, 1, WAVEFORM_SAMPLE_RATE);
|
|
300
341
|
const audioBuffer = await decodeSkippingJunk(context, blob);
|
|
@@ -964,18 +1005,12 @@ const useAudioBlobLoader = (options) => {
|
|
|
964
1005
|
const audioRef = useRef(null);
|
|
965
1006
|
const objectUrlRef = useRef(null);
|
|
966
1007
|
const abortControllerRef = useRef(null);
|
|
967
|
-
const failedSrcRef = useRef(null);
|
|
968
1008
|
const waveformTokenRef = useRef(0);
|
|
969
1009
|
const waveformDataRef = useRef(waveformData);
|
|
970
1010
|
const callbacksRef = useRef(playbackCallbacks);
|
|
971
1011
|
waveformDataRef.current = waveformData;
|
|
972
1012
|
callbacksRef.current = playbackCallbacks;
|
|
973
|
-
const
|
|
974
|
-
if (objectUrlRef.current) {
|
|
975
|
-
revokeObjectURL(objectUrlRef.current);
|
|
976
|
-
}
|
|
977
|
-
const url = createObjectURL(blob);
|
|
978
|
-
objectUrlRef.current = url;
|
|
1013
|
+
const createAudioFromUrl = useCallback((url) => {
|
|
979
1014
|
const { audio } = createAudioPlayback(url, {
|
|
980
1015
|
onLoadedMetadata: (dur) => callbacksRef.current.onLoadedMetadata(dur),
|
|
981
1016
|
onTimeUpdate: (time) => callbacksRef.current.onTimeUpdate(time),
|
|
@@ -991,13 +1026,22 @@ const useAudioBlobLoader = (options) => {
|
|
|
991
1026
|
audioRef.current = audio;
|
|
992
1027
|
setIsLoaded(true);
|
|
993
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]);
|
|
994
1037
|
/**
|
|
995
1038
|
* Waveform is cosmetic: some files (e.g. mp3 with junk before the first
|
|
996
1039
|
* frame) fail to decode in Safari while playing fine, so a decode error
|
|
997
1040
|
* must never block playback. Token keeps only the latest decode.
|
|
998
1041
|
*/
|
|
999
|
-
const applyWaveformLevels = useCallback(async (blob) => {
|
|
1000
|
-
|
|
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) {
|
|
1001
1045
|
return;
|
|
1002
1046
|
}
|
|
1003
1047
|
waveformTokenRef.current += 1;
|
|
@@ -1010,8 +1054,12 @@ const useAudioBlobLoader = (options) => {
|
|
|
1010
1054
|
}
|
|
1011
1055
|
catch (error) {
|
|
1012
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
|
+
}
|
|
1013
1061
|
}
|
|
1014
|
-
}, [
|
|
1062
|
+
}, []);
|
|
1015
1063
|
const mountFromBlob = useCallback(async (blob) => {
|
|
1016
1064
|
if (audioRef.current) {
|
|
1017
1065
|
return;
|
|
@@ -1026,53 +1074,51 @@ const useAudioBlobLoader = (options) => {
|
|
|
1026
1074
|
finally {
|
|
1027
1075
|
setIsLoading(false);
|
|
1028
1076
|
}
|
|
1029
|
-
void applyWaveformLevels(blob);
|
|
1077
|
+
void applyWaveformLevels(blob, `${blob.size}`);
|
|
1030
1078
|
}, [applyWaveformLevels, createAudioFromBlob]);
|
|
1031
|
-
|
|
1079
|
+
/** Waveform bytes are cosmetic, so nothing here may delay playback */
|
|
1080
|
+
const loadWaveformSource = useCallback(async (url, cached) => {
|
|
1032
1081
|
var _a;
|
|
1033
|
-
if (
|
|
1082
|
+
if (waveformDataRef.current) {
|
|
1034
1083
|
return;
|
|
1035
1084
|
}
|
|
1036
|
-
if (
|
|
1037
|
-
|
|
1038
|
-
return;
|
|
1039
|
-
}
|
|
1040
|
-
failedSrcRef.current = null;
|
|
1041
|
-
}
|
|
1042
|
-
if (preloadedBlob) {
|
|
1043
|
-
return mountFromBlob(preloadedBlob);
|
|
1044
|
-
}
|
|
1045
|
-
if (!src) {
|
|
1046
|
-
return;
|
|
1085
|
+
if (cached) {
|
|
1086
|
+
return applyWaveformLevels(cached, url);
|
|
1047
1087
|
}
|
|
1048
1088
|
(_a = abortControllerRef.current) === null || _a === void 0 ? void 0 : _a.abort();
|
|
1049
1089
|
const controller = new AbortController();
|
|
1050
1090
|
abortControllerRef.current = controller;
|
|
1051
|
-
setIsLoading(true);
|
|
1052
1091
|
try {
|
|
1053
|
-
const blob = await fetchAudioBlob(
|
|
1054
|
-
if (controller.signal.aborted) {
|
|
1055
|
-
|
|
1092
|
+
const blob = await fetchAudioBlob(url, controller.signal);
|
|
1093
|
+
if (!controller.signal.aborted) {
|
|
1094
|
+
await applyWaveformLevels(blob, url);
|
|
1056
1095
|
}
|
|
1057
|
-
createAudioFromBlob(blob);
|
|
1058
|
-
void applyWaveformLevels(blob);
|
|
1059
1096
|
}
|
|
1060
1097
|
catch (error) {
|
|
1061
1098
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
1062
1099
|
return;
|
|
1063
1100
|
}
|
|
1064
|
-
|
|
1065
|
-
console.warn('[voice-player] Failed to load audio', error);
|
|
1101
|
+
console.warn('[voice-player] Failed to fetch waveform source', error);
|
|
1066
1102
|
}
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1103
|
+
}, [applyWaveformLevels]);
|
|
1104
|
+
const ensureAudioLoaded = useCallback(async () => {
|
|
1105
|
+
if (audioRef.current) {
|
|
1106
|
+
return;
|
|
1071
1107
|
}
|
|
1072
|
-
|
|
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]);
|
|
1073
1120
|
/** Cleanup on src change or unmount */
|
|
1074
1121
|
useEffect(() => {
|
|
1075
|
-
failedSrcRef.current = null;
|
|
1076
1122
|
/** Drop the previous waveform so a failed decode cannot leave a foreign one */
|
|
1077
1123
|
setWaveformLevels(waveformDataRef.current || createInitialLevels());
|
|
1078
1124
|
return () => {
|
|
@@ -1090,11 +1136,12 @@ const useAudioBlobLoader = (options) => {
|
|
|
1090
1136
|
setIsLoaded(false);
|
|
1091
1137
|
};
|
|
1092
1138
|
}, [src]);
|
|
1139
|
+
/** A cached recording mounts on its own only while it has no url yet */
|
|
1093
1140
|
useEffect(() => {
|
|
1094
|
-
if (preloadedBlob && !audioRef.current) {
|
|
1141
|
+
if (preloadedBlob && !src && !audioRef.current) {
|
|
1095
1142
|
void mountFromBlob(preloadedBlob);
|
|
1096
1143
|
}
|
|
1097
|
-
}, [preloadedBlob, mountFromBlob]);
|
|
1144
|
+
}, [src, preloadedBlob, mountFromBlob]);
|
|
1098
1145
|
useEffect(() => {
|
|
1099
1146
|
if (waveformData) {
|
|
1100
1147
|
waveformTokenRef.current += 1;
|
|
@@ -1203,7 +1250,7 @@ const useVoiceMessagePlayer = (options) => {
|
|
|
1203
1250
|
return;
|
|
1204
1251
|
}
|
|
1205
1252
|
audioPlaybackManager.stopOthers(stopThis);
|
|
1206
|
-
void ensureAudioLoaded(
|
|
1253
|
+
void ensureAudioLoaded().then(() => {
|
|
1207
1254
|
const audio = audioRef.current;
|
|
1208
1255
|
if (audio) {
|
|
1209
1256
|
audio.play()
|
|
@@ -1263,7 +1310,7 @@ const useVoiceMessagePlayer = (options) => {
|
|
|
1263
1310
|
.catch(() => setIsPlaying(false));
|
|
1264
1311
|
}
|
|
1265
1312
|
else {
|
|
1266
|
-
void ensureAudioLoaded(
|
|
1313
|
+
void ensureAudioLoaded().then(() => {
|
|
1267
1314
|
const loadedAudio = audioRef.current;
|
|
1268
1315
|
if (loadedAudio) {
|
|
1269
1316
|
audioPlaybackManager.stopOthers(stopThis);
|