@exode-team/react-recorder 1.1.10 → 1.1.12
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/constants.d.ts +2 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/hooks/useAudioBlobLoader.d.ts +5 -1
- package/dist/hooks/useAudioBlobLoader.d.ts.map +1 -1
- package/dist/index.cjs +143 -40
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +143 -40
- 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
|
@@ -182,6 +182,8 @@ const revokeObjectURL = (url) => {
|
|
|
182
182
|
* @author: exode <hello@exode.ru>
|
|
183
183
|
*/
|
|
184
184
|
const AUDIO_LEVEL_BARS = 40;
|
|
185
|
+
/** Waveform decoding resamples to this rate — only the amplitude envelope matters */
|
|
186
|
+
const WAVEFORM_SAMPLE_RATE = 44100;
|
|
185
187
|
const createInitialLevels = () => Array.from({ length: AUDIO_LEVEL_BARS }, () => 0);
|
|
186
188
|
const TIMELINE_SAMPLE_INTERVAL = 150;
|
|
187
189
|
const TIMELINE_BAR_WIDTH = 3;
|
|
@@ -189,6 +191,64 @@ const TIMELINE_BAR_GAP = 2;
|
|
|
189
191
|
const TIMELINE_BAR_MIN_HEIGHT = 3;
|
|
190
192
|
const TIMELINE_BAR_MAX_HEIGHT = 24;
|
|
191
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
|
+
|
|
192
252
|
/**
|
|
193
253
|
* Audio analyzer platform adapter
|
|
194
254
|
*
|
|
@@ -215,34 +275,48 @@ const readFrequencyLevels = (analyser, barCount = AUDIO_LEVEL_BARS) => {
|
|
|
215
275
|
}
|
|
216
276
|
return levels;
|
|
217
277
|
};
|
|
218
|
-
|
|
219
|
-
|
|
278
|
+
/** decodeAudioData detaches the buffer, so the retry re-reads the blob */
|
|
279
|
+
const decodeSkippingJunk = async (context, blob) => {
|
|
220
280
|
try {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const segmentLength = Math.max(1, Math.floor(channelData.length / barCount));
|
|
229
|
-
for (let i = 0; i < barCount; i += 1) {
|
|
230
|
-
const segmentStart = i * segmentLength;
|
|
231
|
-
let sum = 0;
|
|
232
|
-
for (let j = 0; j < segmentLength; j += 1) {
|
|
233
|
-
const sample = channelData[segmentStart + j];
|
|
234
|
-
if (sample !== undefined) {
|
|
235
|
-
sum += Math.abs(sample);
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
const average = sum / segmentLength;
|
|
239
|
-
levels.push(Math.min(100, average * 200));
|
|
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;
|
|
240
288
|
}
|
|
241
|
-
return
|
|
289
|
+
return context.decodeAudioData(bytes.buffer.slice(offset));
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
const decodeAudioLevels = async (blob, barCount = AUDIO_LEVEL_BARS) => {
|
|
293
|
+
/**
|
|
294
|
+
* Offline context stays out of the audio session. A realtime AudioContext
|
|
295
|
+
* registers as a WebAudio source, which pins WebKit to the AmbientSound
|
|
296
|
+
* category and leaves media playback silent inside iOS apps — including
|
|
297
|
+
* iPad apps running on macOS.
|
|
298
|
+
*/
|
|
299
|
+
const context = new OfflineAudioContext(1, 1, WAVEFORM_SAMPLE_RATE);
|
|
300
|
+
const audioBuffer = await decodeSkippingJunk(context, blob);
|
|
301
|
+
const channelData = audioBuffer.getChannelData(0);
|
|
302
|
+
if (!channelData.length) {
|
|
303
|
+
return Array.from({ length: barCount }, () => 0);
|
|
242
304
|
}
|
|
243
|
-
|
|
244
|
-
|
|
305
|
+
const levels = [];
|
|
306
|
+
const segmentLength = Math.max(1, Math.floor(channelData.length / barCount));
|
|
307
|
+
for (let i = 0; i < barCount; i += 1) {
|
|
308
|
+
const segmentStart = i * segmentLength;
|
|
309
|
+
let sum = 0;
|
|
310
|
+
for (let j = 0; j < segmentLength; j += 1) {
|
|
311
|
+
const sample = channelData[segmentStart + j];
|
|
312
|
+
if (sample !== undefined) {
|
|
313
|
+
sum += Math.abs(sample);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const average = sum / segmentLength;
|
|
317
|
+
levels.push(Math.min(100, average * 200));
|
|
245
318
|
}
|
|
319
|
+
return levels;
|
|
246
320
|
};
|
|
247
321
|
|
|
248
322
|
/**
|
|
@@ -256,6 +330,9 @@ const fetchAudioBlob = async (url, signal) => {
|
|
|
256
330
|
mode: 'cors',
|
|
257
331
|
credentials: 'omit',
|
|
258
332
|
});
|
|
333
|
+
if (!response.ok) {
|
|
334
|
+
throw new Error(`Failed to fetch audio: ${response.status}`);
|
|
335
|
+
}
|
|
259
336
|
return response.blob();
|
|
260
337
|
};
|
|
261
338
|
|
|
@@ -888,7 +965,10 @@ const useAudioBlobLoader = (options) => {
|
|
|
888
965
|
const objectUrlRef = useRef(null);
|
|
889
966
|
const abortControllerRef = useRef(null);
|
|
890
967
|
const failedSrcRef = useRef(null);
|
|
968
|
+
const waveformTokenRef = useRef(0);
|
|
969
|
+
const waveformDataRef = useRef(waveformData);
|
|
891
970
|
const callbacksRef = useRef(playbackCallbacks);
|
|
971
|
+
waveformDataRef.current = waveformData;
|
|
892
972
|
callbacksRef.current = playbackCallbacks;
|
|
893
973
|
const createAudioFromBlob = useCallback((blob) => {
|
|
894
974
|
if (objectUrlRef.current) {
|
|
@@ -911,15 +991,33 @@ const useAudioBlobLoader = (options) => {
|
|
|
911
991
|
audioRef.current = audio;
|
|
912
992
|
setIsLoaded(true);
|
|
913
993
|
}, []);
|
|
994
|
+
/**
|
|
995
|
+
* Waveform is cosmetic: some files (e.g. mp3 with junk before the first
|
|
996
|
+
* frame) fail to decode in Safari while playing fine, so a decode error
|
|
997
|
+
* must never block playback. Token keeps only the latest decode.
|
|
998
|
+
*/
|
|
999
|
+
const applyWaveformLevels = useCallback(async (blob) => {
|
|
1000
|
+
if (waveformData) {
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
waveformTokenRef.current += 1;
|
|
1004
|
+
const token = waveformTokenRef.current;
|
|
1005
|
+
try {
|
|
1006
|
+
const levels = await decodeAudioLevels(blob);
|
|
1007
|
+
if (token === waveformTokenRef.current) {
|
|
1008
|
+
setWaveformLevels(levels);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
catch (error) {
|
|
1012
|
+
console.warn('[voice-player] Failed to decode waveform', error);
|
|
1013
|
+
}
|
|
1014
|
+
}, [waveformData]);
|
|
914
1015
|
const mountFromBlob = useCallback(async (blob) => {
|
|
915
1016
|
if (audioRef.current) {
|
|
916
1017
|
return;
|
|
917
1018
|
}
|
|
918
1019
|
setIsLoading(true);
|
|
919
1020
|
try {
|
|
920
|
-
if (!waveformData) {
|
|
921
|
-
setWaveformLevels(await decodeAudioLevels(blob));
|
|
922
|
-
}
|
|
923
1021
|
createAudioFromBlob(blob);
|
|
924
1022
|
}
|
|
925
1023
|
catch (error) {
|
|
@@ -928,12 +1026,19 @@ const useAudioBlobLoader = (options) => {
|
|
|
928
1026
|
finally {
|
|
929
1027
|
setIsLoading(false);
|
|
930
1028
|
}
|
|
931
|
-
|
|
932
|
-
|
|
1029
|
+
void applyWaveformLevels(blob);
|
|
1030
|
+
}, [applyWaveformLevels, createAudioFromBlob]);
|
|
1031
|
+
const ensureAudioLoaded = useCallback(async (options) => {
|
|
933
1032
|
var _a;
|
|
934
|
-
if (audioRef.current
|
|
1033
|
+
if (audioRef.current) {
|
|
935
1034
|
return;
|
|
936
1035
|
}
|
|
1036
|
+
if (failedSrcRef.current === src) {
|
|
1037
|
+
if (!(options === null || options === void 0 ? void 0 : options.retryFailed)) {
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
failedSrcRef.current = null;
|
|
1041
|
+
}
|
|
937
1042
|
if (preloadedBlob) {
|
|
938
1043
|
return mountFromBlob(preloadedBlob);
|
|
939
1044
|
}
|
|
@@ -949,14 +1054,8 @@ const useAudioBlobLoader = (options) => {
|
|
|
949
1054
|
if (controller.signal.aborted) {
|
|
950
1055
|
return;
|
|
951
1056
|
}
|
|
952
|
-
if (!waveformData) {
|
|
953
|
-
const levels = await decodeAudioLevels(blob);
|
|
954
|
-
if (controller.signal.aborted) {
|
|
955
|
-
return;
|
|
956
|
-
}
|
|
957
|
-
setWaveformLevels(levels);
|
|
958
|
-
}
|
|
959
1057
|
createAudioFromBlob(blob);
|
|
1058
|
+
void applyWaveformLevels(blob);
|
|
960
1059
|
}
|
|
961
1060
|
catch (error) {
|
|
962
1061
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
@@ -970,13 +1069,16 @@ const useAudioBlobLoader = (options) => {
|
|
|
970
1069
|
setIsLoading(false);
|
|
971
1070
|
}
|
|
972
1071
|
}
|
|
973
|
-
}, [src,
|
|
1072
|
+
}, [src, preloadedBlob, mountFromBlob, applyWaveformLevels, createAudioFromBlob]);
|
|
974
1073
|
/** Cleanup on src change or unmount */
|
|
975
1074
|
useEffect(() => {
|
|
976
1075
|
failedSrcRef.current = null;
|
|
1076
|
+
/** Drop the previous waveform so a failed decode cannot leave a foreign one */
|
|
1077
|
+
setWaveformLevels(waveformDataRef.current || createInitialLevels());
|
|
977
1078
|
return () => {
|
|
978
1079
|
var _a;
|
|
979
1080
|
(_a = abortControllerRef.current) === null || _a === void 0 ? void 0 : _a.abort();
|
|
1081
|
+
waveformTokenRef.current += 1;
|
|
980
1082
|
if (audioRef.current) {
|
|
981
1083
|
cleanupAudio(audioRef.current);
|
|
982
1084
|
audioRef.current = null;
|
|
@@ -995,6 +1097,7 @@ const useAudioBlobLoader = (options) => {
|
|
|
995
1097
|
}, [preloadedBlob, mountFromBlob]);
|
|
996
1098
|
useEffect(() => {
|
|
997
1099
|
if (waveformData) {
|
|
1100
|
+
waveformTokenRef.current += 1;
|
|
998
1101
|
setWaveformLevels(waveformData);
|
|
999
1102
|
}
|
|
1000
1103
|
}, [waveformData]);
|
|
@@ -1100,7 +1203,7 @@ const useVoiceMessagePlayer = (options) => {
|
|
|
1100
1203
|
return;
|
|
1101
1204
|
}
|
|
1102
1205
|
audioPlaybackManager.stopOthers(stopThis);
|
|
1103
|
-
void ensureAudioLoaded().then(() => {
|
|
1206
|
+
void ensureAudioLoaded({ retryFailed: true }).then(() => {
|
|
1104
1207
|
const audio = audioRef.current;
|
|
1105
1208
|
if (audio) {
|
|
1106
1209
|
audio.play()
|
|
@@ -1160,7 +1263,7 @@ const useVoiceMessagePlayer = (options) => {
|
|
|
1160
1263
|
.catch(() => setIsPlaying(false));
|
|
1161
1264
|
}
|
|
1162
1265
|
else {
|
|
1163
|
-
void ensureAudioLoaded().then(() => {
|
|
1266
|
+
void ensureAudioLoaded({ retryFailed: true }).then(() => {
|
|
1164
1267
|
const loadedAudio = audioRef.current;
|
|
1165
1268
|
if (loadedAudio) {
|
|
1166
1269
|
audioPlaybackManager.stopOthers(stopThis);
|