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