@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.
- package/dist/hooks/useAudioBlobLoader.d.ts +5 -1
- package/dist/hooks/useAudioBlobLoader.d.ts.map +1 -1
- package/dist/index.cjs +118 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +118 -18
- package/dist/index.js.map +1 -1
- package/dist/platform/audio-analyzer-adapter.d.ts.map +1 -1
- package/dist/platform/audio-blob-fetcher.d.ts.map +1 -1
- package/dist/platform/mpeg-frame.d.ts +12 -0
- package/dist/platform/mpeg-frame.d.ts.map +1 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -189,6 +189,64 @@ const TIMELINE_BAR_GAP = 2;
|
|
|
189
189
|
const TIMELINE_BAR_MIN_HEIGHT = 3;
|
|
190
190
|
const TIMELINE_BAR_MAX_HEIGHT = 24;
|
|
191
191
|
|
|
192
|
+
/**
|
|
193
|
+
* MPEG frame scanner
|
|
194
|
+
*
|
|
195
|
+
* @author: exode <hello@exode.ru>
|
|
196
|
+
*/
|
|
197
|
+
const MPEG_LAYER_III = 1;
|
|
198
|
+
const MPEG_SAMPLE_RATES = [44100, 48000, 32000];
|
|
199
|
+
const MPEG_BITRATES_V1 = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0];
|
|
200
|
+
const MPEG_BITRATES_V2 = [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0];
|
|
201
|
+
/** Length of the Layer III frame at the given offset, or 0 when it is not one */
|
|
202
|
+
const readFrameLength = (bytes, offset) => {
|
|
203
|
+
if (offset + 3 >= bytes.length || bytes[offset] !== 0xff || (bytes[offset + 1] & 0xe0) !== 0xe0) {
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
/** Version ids: 0 = MPEG 2.5, 1 = reserved, 2 = MPEG 2, 3 = MPEG 1 */
|
|
207
|
+
const version = (bytes[offset + 1] >> 3) & 0x03;
|
|
208
|
+
const layer = (bytes[offset + 1] >> 1) & 0x03;
|
|
209
|
+
const bitrateIndex = (bytes[offset + 2] >> 4) & 0x0f;
|
|
210
|
+
const sampleRateIndex = (bytes[offset + 2] >> 2) & 0x03;
|
|
211
|
+
const isReserved = version === 1
|
|
212
|
+
|| layer !== MPEG_LAYER_III
|
|
213
|
+
|| sampleRateIndex === 3
|
|
214
|
+
|| bitrateIndex === 0
|
|
215
|
+
|| bitrateIndex === 0x0f;
|
|
216
|
+
if (isReserved) {
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
const isVersion1 = version === 3;
|
|
220
|
+
const padding = (bytes[offset + 2] >> 1) & 0x01;
|
|
221
|
+
const bitrate = (isVersion1 ? MPEG_BITRATES_V1 : MPEG_BITRATES_V2)[bitrateIndex] * 1000;
|
|
222
|
+
const sampleRate = MPEG_SAMPLE_RATES[sampleRateIndex] / (isVersion1 ? 1 : version === 2 ? 2 : 4);
|
|
223
|
+
return Math.floor((isVersion1 ? 144 : 72) * bitrate / sampleRate) + padding;
|
|
224
|
+
};
|
|
225
|
+
/**
|
|
226
|
+
* Some mp3 (e.g. converted from wma) keep junk between the ID3 tag and the
|
|
227
|
+
* first MPEG frame: media elements skip it, the WebAudio decoder does not.
|
|
228
|
+
* Returns the offset of the first real frame, or 0 when there is none.
|
|
229
|
+
*/
|
|
230
|
+
const findFirstFrameOffset = (bytes) => {
|
|
231
|
+
let start = 0;
|
|
232
|
+
/** ID3v2 header: "ID3" + version + flags + 4 syncsafe size bytes */
|
|
233
|
+
if (bytes.length >= 10 && bytes[0] === 0x49 && bytes[1] === 0x44 && bytes[2] === 0x33) {
|
|
234
|
+
start = 10 + ((bytes[6] << 21) | (bytes[7] << 14) | (bytes[8] << 7) | bytes[9]);
|
|
235
|
+
/** ID3v2.4 may append a 10 byte footer copy of the header */
|
|
236
|
+
if (bytes[5] & 0x10) {
|
|
237
|
+
start += 10;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
for (let i = start; i < bytes.length - 3; i += 1) {
|
|
241
|
+
const length = readFrameLength(bytes, i);
|
|
242
|
+
/** Junk can open with a valid looking header, so require the next frame to follow */
|
|
243
|
+
if (length && readFrameLength(bytes, i + length)) {
|
|
244
|
+
return i;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return 0;
|
|
248
|
+
};
|
|
249
|
+
|
|
192
250
|
/**
|
|
193
251
|
* Audio analyzer platform adapter
|
|
194
252
|
*
|
|
@@ -215,11 +273,24 @@ const readFrequencyLevels = (analyser, barCount = AUDIO_LEVEL_BARS) => {
|
|
|
215
273
|
}
|
|
216
274
|
return levels;
|
|
217
275
|
};
|
|
276
|
+
/** decodeAudioData detaches the buffer, so the retry re-reads the blob */
|
|
277
|
+
const decodeSkippingJunk = async (context, blob) => {
|
|
278
|
+
try {
|
|
279
|
+
return await context.decodeAudioData(await blob.arrayBuffer());
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
283
|
+
const offset = findFirstFrameOffset(bytes);
|
|
284
|
+
if (!offset) {
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
return context.decodeAudioData(bytes.buffer.slice(offset));
|
|
288
|
+
}
|
|
289
|
+
};
|
|
218
290
|
const decodeAudioLevels = async (blob, barCount = AUDIO_LEVEL_BARS) => {
|
|
219
291
|
const context = new AudioContext();
|
|
220
292
|
try {
|
|
221
|
-
const
|
|
222
|
-
const audioBuffer = await context.decodeAudioData(arrayBuffer);
|
|
293
|
+
const audioBuffer = await decodeSkippingJunk(context, blob);
|
|
223
294
|
const channelData = audioBuffer.getChannelData(0);
|
|
224
295
|
if (!channelData.length) {
|
|
225
296
|
return Array.from({ length: barCount }, () => 0);
|
|
@@ -256,6 +327,9 @@ const fetchAudioBlob = async (url, signal) => {
|
|
|
256
327
|
mode: 'cors',
|
|
257
328
|
credentials: 'omit',
|
|
258
329
|
});
|
|
330
|
+
if (!response.ok) {
|
|
331
|
+
throw new Error(`Failed to fetch audio: ${response.status}`);
|
|
332
|
+
}
|
|
259
333
|
return response.blob();
|
|
260
334
|
};
|
|
261
335
|
|
|
@@ -888,7 +962,10 @@ const useAudioBlobLoader = (options) => {
|
|
|
888
962
|
const objectUrlRef = useRef(null);
|
|
889
963
|
const abortControllerRef = useRef(null);
|
|
890
964
|
const failedSrcRef = useRef(null);
|
|
965
|
+
const waveformTokenRef = useRef(0);
|
|
966
|
+
const waveformDataRef = useRef(waveformData);
|
|
891
967
|
const callbacksRef = useRef(playbackCallbacks);
|
|
968
|
+
waveformDataRef.current = waveformData;
|
|
892
969
|
callbacksRef.current = playbackCallbacks;
|
|
893
970
|
const createAudioFromBlob = useCallback((blob) => {
|
|
894
971
|
if (objectUrlRef.current) {
|
|
@@ -911,15 +988,33 @@ const useAudioBlobLoader = (options) => {
|
|
|
911
988
|
audioRef.current = audio;
|
|
912
989
|
setIsLoaded(true);
|
|
913
990
|
}, []);
|
|
991
|
+
/**
|
|
992
|
+
* Waveform is cosmetic: some files (e.g. mp3 with junk before the first
|
|
993
|
+
* frame) fail to decode in Safari while playing fine, so a decode error
|
|
994
|
+
* must never block playback. Token keeps only the latest decode.
|
|
995
|
+
*/
|
|
996
|
+
const applyWaveformLevels = useCallback(async (blob) => {
|
|
997
|
+
if (waveformData) {
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
waveformTokenRef.current += 1;
|
|
1001
|
+
const token = waveformTokenRef.current;
|
|
1002
|
+
try {
|
|
1003
|
+
const levels = await decodeAudioLevels(blob);
|
|
1004
|
+
if (token === waveformTokenRef.current) {
|
|
1005
|
+
setWaveformLevels(levels);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
catch (error) {
|
|
1009
|
+
console.warn('[voice-player] Failed to decode waveform', error);
|
|
1010
|
+
}
|
|
1011
|
+
}, [waveformData]);
|
|
914
1012
|
const mountFromBlob = useCallback(async (blob) => {
|
|
915
1013
|
if (audioRef.current) {
|
|
916
1014
|
return;
|
|
917
1015
|
}
|
|
918
1016
|
setIsLoading(true);
|
|
919
1017
|
try {
|
|
920
|
-
if (!waveformData) {
|
|
921
|
-
setWaveformLevels(await decodeAudioLevels(blob));
|
|
922
|
-
}
|
|
923
1018
|
createAudioFromBlob(blob);
|
|
924
1019
|
}
|
|
925
1020
|
catch (error) {
|
|
@@ -928,12 +1023,19 @@ const useAudioBlobLoader = (options) => {
|
|
|
928
1023
|
finally {
|
|
929
1024
|
setIsLoading(false);
|
|
930
1025
|
}
|
|
931
|
-
|
|
932
|
-
|
|
1026
|
+
void applyWaveformLevels(blob);
|
|
1027
|
+
}, [applyWaveformLevels, createAudioFromBlob]);
|
|
1028
|
+
const ensureAudioLoaded = useCallback(async (options) => {
|
|
933
1029
|
var _a;
|
|
934
|
-
if (audioRef.current
|
|
1030
|
+
if (audioRef.current) {
|
|
935
1031
|
return;
|
|
936
1032
|
}
|
|
1033
|
+
if (failedSrcRef.current === src) {
|
|
1034
|
+
if (!(options === null || options === void 0 ? void 0 : options.retryFailed)) {
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
failedSrcRef.current = null;
|
|
1038
|
+
}
|
|
937
1039
|
if (preloadedBlob) {
|
|
938
1040
|
return mountFromBlob(preloadedBlob);
|
|
939
1041
|
}
|
|
@@ -949,14 +1051,8 @@ const useAudioBlobLoader = (options) => {
|
|
|
949
1051
|
if (controller.signal.aborted) {
|
|
950
1052
|
return;
|
|
951
1053
|
}
|
|
952
|
-
if (!waveformData) {
|
|
953
|
-
const levels = await decodeAudioLevels(blob);
|
|
954
|
-
if (controller.signal.aborted) {
|
|
955
|
-
return;
|
|
956
|
-
}
|
|
957
|
-
setWaveformLevels(levels);
|
|
958
|
-
}
|
|
959
1054
|
createAudioFromBlob(blob);
|
|
1055
|
+
void applyWaveformLevels(blob);
|
|
960
1056
|
}
|
|
961
1057
|
catch (error) {
|
|
962
1058
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
@@ -970,13 +1066,16 @@ const useAudioBlobLoader = (options) => {
|
|
|
970
1066
|
setIsLoading(false);
|
|
971
1067
|
}
|
|
972
1068
|
}
|
|
973
|
-
}, [src,
|
|
1069
|
+
}, [src, preloadedBlob, mountFromBlob, applyWaveformLevels, createAudioFromBlob]);
|
|
974
1070
|
/** Cleanup on src change or unmount */
|
|
975
1071
|
useEffect(() => {
|
|
976
1072
|
failedSrcRef.current = null;
|
|
1073
|
+
/** Drop the previous waveform so a failed decode cannot leave a foreign one */
|
|
1074
|
+
setWaveformLevels(waveformDataRef.current || createInitialLevels());
|
|
977
1075
|
return () => {
|
|
978
1076
|
var _a;
|
|
979
1077
|
(_a = abortControllerRef.current) === null || _a === void 0 ? void 0 : _a.abort();
|
|
1078
|
+
waveformTokenRef.current += 1;
|
|
980
1079
|
if (audioRef.current) {
|
|
981
1080
|
cleanupAudio(audioRef.current);
|
|
982
1081
|
audioRef.current = null;
|
|
@@ -995,6 +1094,7 @@ const useAudioBlobLoader = (options) => {
|
|
|
995
1094
|
}, [preloadedBlob, mountFromBlob]);
|
|
996
1095
|
useEffect(() => {
|
|
997
1096
|
if (waveformData) {
|
|
1097
|
+
waveformTokenRef.current += 1;
|
|
998
1098
|
setWaveformLevels(waveformData);
|
|
999
1099
|
}
|
|
1000
1100
|
}, [waveformData]);
|
|
@@ -1100,7 +1200,7 @@ const useVoiceMessagePlayer = (options) => {
|
|
|
1100
1200
|
return;
|
|
1101
1201
|
}
|
|
1102
1202
|
audioPlaybackManager.stopOthers(stopThis);
|
|
1103
|
-
void ensureAudioLoaded().then(() => {
|
|
1203
|
+
void ensureAudioLoaded({ retryFailed: true }).then(() => {
|
|
1104
1204
|
const audio = audioRef.current;
|
|
1105
1205
|
if (audio) {
|
|
1106
1206
|
audio.play()
|
|
@@ -1160,7 +1260,7 @@ const useVoiceMessagePlayer = (options) => {
|
|
|
1160
1260
|
.catch(() => setIsPlaying(false));
|
|
1161
1261
|
}
|
|
1162
1262
|
else {
|
|
1163
|
-
void ensureAudioLoaded().then(() => {
|
|
1263
|
+
void ensureAudioLoaded({ retryFailed: true }).then(() => {
|
|
1164
1264
|
const loadedAudio = audioRef.current;
|
|
1165
1265
|
if (loadedAudio) {
|
|
1166
1266
|
audioPlaybackManager.stopOthers(stopThis);
|