@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/README.md
CHANGED
|
@@ -146,7 +146,7 @@ const Messages = () => {
|
|
|
146
146
|
| `duration` | `number` | — | — | Длительность в секундах (отображается до загрузки аудио). |
|
|
147
147
|
| `autoPreload` | `boolean` | — | `true` | Автозагрузка аудио при попадании в viewport (IntersectionObserver). |
|
|
148
148
|
| `waveformData` | `number[]` | — | — | Предварительно вычисленные уровни waveform. |
|
|
149
|
-
| `preloadedBlob` | `Blob \| null` | — | — |
|
|
149
|
+
| `preloadedBlob` | `Blob \| null` | — | — | Локальная запись. Играет из него, только пока нет `src`; при заданном `src` идёт на него, а blob переиспользуется для waveform. |
|
|
150
150
|
| `onEnded` | `() => void` | — | — | Колбэк окончания воспроизведения. |
|
|
151
151
|
| `className` | `string` | — | — | CSS-класс контейнера. |
|
|
152
152
|
| `onClick` | `(e: React.MouseEvent) => void`| — | — | Обработчик клика по контейнеру. |
|
|
@@ -154,7 +154,7 @@ const Messages = () => {
|
|
|
154
154
|
**Ref API (`VoiceMessagePlayerRef`):**
|
|
155
155
|
|
|
156
156
|
- `play()` — программный запуск воспроизведения.
|
|
157
|
-
- `prefetch()` —
|
|
157
|
+
- `prefetch()` — подготовка аудио без воспроизведения (waveform считается фоном и play не задерживает).
|
|
158
158
|
|
|
159
159
|
#### `VoicePlaybackChain`
|
|
160
160
|
|
|
@@ -11,16 +11,12 @@ 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
|
-
}
|
|
18
14
|
interface UseAudioBlobLoaderResult {
|
|
19
15
|
audioRef: React.MutableRefObject<HTMLAudioElement | null>;
|
|
20
16
|
isLoading: boolean;
|
|
21
17
|
isLoaded: boolean;
|
|
22
18
|
waveformLevels: number[];
|
|
23
|
-
ensureAudioLoaded: (
|
|
19
|
+
ensureAudioLoaded: () => Promise<void>;
|
|
24
20
|
}
|
|
25
21
|
declare const useAudioBlobLoader: (options: UseAudioBlobLoaderOptions) => UseAudioBlobLoaderResult;
|
|
26
22
|
export { useAudioBlobLoader };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useAudioBlobLoader.d.ts","sourceRoot":"","sources":["../../src/hooks/useAudioBlobLoader.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;
|
|
1
|
+
{"version":3,"file":"useAudioBlobLoader.d.ts","sourceRoot":"","sources":["../../src/hooks/useAudioBlobLoader.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,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,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1C;AAED,QAAA,MAAM,kBAAkB,GAAI,SAAS,yBAAyB,KAAG,wBAyLhE,CAAC;AAGF,OAAO,EAAE,kBAAkB,EAAE,CAAC"}
|
package/dist/index.cjs
CHANGED
|
@@ -30,6 +30,56 @@ const formatTime = (seconds) => {
|
|
|
30
30
|
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* AudioRecorder constants
|
|
35
|
+
*
|
|
36
|
+
* @author: exode <hello@exode.ru>
|
|
37
|
+
*/
|
|
38
|
+
const AUDIO_LEVEL_BARS = 40;
|
|
39
|
+
/** Waveform decoding resamples to this rate — only the amplitude envelope matters */
|
|
40
|
+
const WAVEFORM_SAMPLE_RATE = 44100;
|
|
41
|
+
const createInitialLevels = () => Array.from({ length: AUDIO_LEVEL_BARS }, () => 0);
|
|
42
|
+
const TIMELINE_SAMPLE_INTERVAL = 150;
|
|
43
|
+
const TIMELINE_BAR_WIDTH = 3;
|
|
44
|
+
const TIMELINE_BAR_GAP = 2;
|
|
45
|
+
const TIMELINE_BAR_MIN_HEIGHT = 3;
|
|
46
|
+
const TIMELINE_BAR_MAX_HEIGHT = 24;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Pseudo waveform utilities
|
|
50
|
+
*
|
|
51
|
+
* @author: exode <hello@exode.ru>
|
|
52
|
+
*/
|
|
53
|
+
/** Deterministic generator: the same source always draws the same waveform */
|
|
54
|
+
const seededRandom = (seed) => {
|
|
55
|
+
let state = Math.abs(Math.floor(seed)) % 2147483647 || 1;
|
|
56
|
+
return () => (state = state * 16807 % 2147483647) / 2147483647;
|
|
57
|
+
};
|
|
58
|
+
const hashString = (value) => {
|
|
59
|
+
let hash = 0;
|
|
60
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
61
|
+
hash = (hash * 31 + value.charCodeAt(i)) | 0;
|
|
62
|
+
}
|
|
63
|
+
return hash;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Decorative levels for runtimes that cannot decode audio at all — WebAudio
|
|
67
|
+
* rejects every mp3 inside iPad apps running on macOS. Purely cosmetic: it
|
|
68
|
+
* stands in for a flat line and says nothing about the recording itself.
|
|
69
|
+
*/
|
|
70
|
+
const createPseudoLevels = (seed, barCount = AUDIO_LEVEL_BARS) => {
|
|
71
|
+
const random = seededRandom(hashString(seed));
|
|
72
|
+
const levels = [];
|
|
73
|
+
let level = 45 + random() * 25;
|
|
74
|
+
for (let i = 0; i < barCount; i += 1) {
|
|
75
|
+
/** Smooth drift instead of pure noise, so it reads as speech rather than static */
|
|
76
|
+
level = clampValue(level + (random() - 0.5) * 42, 14);
|
|
77
|
+
/** Slight fade at both ends — that is how a real recording usually looks */
|
|
78
|
+
levels.push(level * (0.72 + 0.28 * Math.sin((i / (barCount - 1)) * Math.PI)));
|
|
79
|
+
}
|
|
80
|
+
return levels;
|
|
81
|
+
};
|
|
82
|
+
|
|
33
83
|
/**
|
|
34
84
|
* Microphone platform adapter
|
|
35
85
|
*
|
|
@@ -135,6 +185,12 @@ const clearMediaSession = () => {
|
|
|
135
185
|
};
|
|
136
186
|
const createAudioPlayback = (url, callbacks) => {
|
|
137
187
|
const audio = new Audio(url);
|
|
188
|
+
/**
|
|
189
|
+
* A detached element is not reliably rendered by every WebKit runtime
|
|
190
|
+
* (notably iPad apps running on macOS), so keep it in the document.
|
|
191
|
+
*/
|
|
192
|
+
audio.style.display = 'none';
|
|
193
|
+
document.body.appendChild(audio);
|
|
138
194
|
audio.onloadedmetadata = () => {
|
|
139
195
|
callbacks.onLoadedMetadata(audio.duration);
|
|
140
196
|
};
|
|
@@ -163,6 +219,7 @@ const createAudioPlayback = (url, callbacks) => {
|
|
|
163
219
|
};
|
|
164
220
|
const cleanupAudio = (audio) => {
|
|
165
221
|
audio.pause();
|
|
222
|
+
audio.remove();
|
|
166
223
|
audio.src = '';
|
|
167
224
|
audio.onended = null;
|
|
168
225
|
audio.ontimeupdate = null;
|
|
@@ -178,21 +235,6 @@ const revokeObjectURL = (url) => {
|
|
|
178
235
|
URL.revokeObjectURL(url);
|
|
179
236
|
};
|
|
180
237
|
|
|
181
|
-
/**
|
|
182
|
-
* AudioRecorder constants
|
|
183
|
-
*
|
|
184
|
-
* @author: exode <hello@exode.ru>
|
|
185
|
-
*/
|
|
186
|
-
const AUDIO_LEVEL_BARS = 40;
|
|
187
|
-
/** Waveform decoding resamples to this rate — only the amplitude envelope matters */
|
|
188
|
-
const WAVEFORM_SAMPLE_RATE = 44100;
|
|
189
|
-
const createInitialLevels = () => Array.from({ length: AUDIO_LEVEL_BARS }, () => 0);
|
|
190
|
-
const TIMELINE_SAMPLE_INTERVAL = 150;
|
|
191
|
-
const TIMELINE_BAR_WIDTH = 3;
|
|
192
|
-
const TIMELINE_BAR_GAP = 2;
|
|
193
|
-
const TIMELINE_BAR_MIN_HEIGHT = 3;
|
|
194
|
-
const TIMELINE_BAR_MAX_HEIGHT = 24;
|
|
195
|
-
|
|
196
238
|
/**
|
|
197
239
|
* MPEG frame scanner
|
|
198
240
|
*
|
|
@@ -293,10 +335,9 @@ const decodeSkippingJunk = async (context, blob) => {
|
|
|
293
335
|
};
|
|
294
336
|
const decodeAudioLevels = async (blob, barCount = AUDIO_LEVEL_BARS) => {
|
|
295
337
|
/**
|
|
296
|
-
* Offline
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
* iPad apps running on macOS.
|
|
338
|
+
* Offline rendering keeps the audio hardware untouched. Runtimes that
|
|
339
|
+
* cannot decode at all (iPad apps on macOS reject every mp3) fall through
|
|
340
|
+
* to the caller, which draws a placeholder waveform instead.
|
|
300
341
|
*/
|
|
301
342
|
const context = new OfflineAudioContext(1, 1, WAVEFORM_SAMPLE_RATE);
|
|
302
343
|
const audioBuffer = await decodeSkippingJunk(context, blob);
|
|
@@ -966,18 +1007,12 @@ const useAudioBlobLoader = (options) => {
|
|
|
966
1007
|
const audioRef = React.useRef(null);
|
|
967
1008
|
const objectUrlRef = React.useRef(null);
|
|
968
1009
|
const abortControllerRef = React.useRef(null);
|
|
969
|
-
const failedSrcRef = React.useRef(null);
|
|
970
1010
|
const waveformTokenRef = React.useRef(0);
|
|
971
1011
|
const waveformDataRef = React.useRef(waveformData);
|
|
972
1012
|
const callbacksRef = React.useRef(playbackCallbacks);
|
|
973
1013
|
waveformDataRef.current = waveformData;
|
|
974
1014
|
callbacksRef.current = playbackCallbacks;
|
|
975
|
-
const
|
|
976
|
-
if (objectUrlRef.current) {
|
|
977
|
-
revokeObjectURL(objectUrlRef.current);
|
|
978
|
-
}
|
|
979
|
-
const url = createObjectURL(blob);
|
|
980
|
-
objectUrlRef.current = url;
|
|
1015
|
+
const createAudioFromUrl = React.useCallback((url) => {
|
|
981
1016
|
const { audio } = createAudioPlayback(url, {
|
|
982
1017
|
onLoadedMetadata: (dur) => callbacksRef.current.onLoadedMetadata(dur),
|
|
983
1018
|
onTimeUpdate: (time) => callbacksRef.current.onTimeUpdate(time),
|
|
@@ -993,13 +1028,22 @@ const useAudioBlobLoader = (options) => {
|
|
|
993
1028
|
audioRef.current = audio;
|
|
994
1029
|
setIsLoaded(true);
|
|
995
1030
|
}, []);
|
|
1031
|
+
/** Local recordings have no url of their own, so they still go through a blob */
|
|
1032
|
+
const createAudioFromBlob = React.useCallback((blob) => {
|
|
1033
|
+
if (objectUrlRef.current) {
|
|
1034
|
+
revokeObjectURL(objectUrlRef.current);
|
|
1035
|
+
}
|
|
1036
|
+
objectUrlRef.current = createObjectURL(blob);
|
|
1037
|
+
createAudioFromUrl(objectUrlRef.current);
|
|
1038
|
+
}, [createAudioFromUrl]);
|
|
996
1039
|
/**
|
|
997
1040
|
* Waveform is cosmetic: some files (e.g. mp3 with junk before the first
|
|
998
1041
|
* frame) fail to decode in Safari while playing fine, so a decode error
|
|
999
1042
|
* must never block playback. Token keeps only the latest decode.
|
|
1000
1043
|
*/
|
|
1001
|
-
const applyWaveformLevels = React.useCallback(async (blob) => {
|
|
1002
|
-
|
|
1044
|
+
const applyWaveformLevels = React.useCallback(async (blob, seed) => {
|
|
1045
|
+
/** Read through the ref: levels may arrive while the bytes are still loading */
|
|
1046
|
+
if (waveformDataRef.current) {
|
|
1003
1047
|
return;
|
|
1004
1048
|
}
|
|
1005
1049
|
waveformTokenRef.current += 1;
|
|
@@ -1012,8 +1056,12 @@ const useAudioBlobLoader = (options) => {
|
|
|
1012
1056
|
}
|
|
1013
1057
|
catch (error) {
|
|
1014
1058
|
console.warn('[voice-player] Failed to decode waveform', error);
|
|
1059
|
+
/** Some runtimes decode nothing at all — draw a placeholder instead of a flat line */
|
|
1060
|
+
if (token === waveformTokenRef.current) {
|
|
1061
|
+
setWaveformLevels(createPseudoLevels(seed));
|
|
1062
|
+
}
|
|
1015
1063
|
}
|
|
1016
|
-
}, [
|
|
1064
|
+
}, []);
|
|
1017
1065
|
const mountFromBlob = React.useCallback(async (blob) => {
|
|
1018
1066
|
if (audioRef.current) {
|
|
1019
1067
|
return;
|
|
@@ -1028,53 +1076,51 @@ const useAudioBlobLoader = (options) => {
|
|
|
1028
1076
|
finally {
|
|
1029
1077
|
setIsLoading(false);
|
|
1030
1078
|
}
|
|
1031
|
-
void applyWaveformLevels(blob);
|
|
1079
|
+
void applyWaveformLevels(blob, `${blob.size}`);
|
|
1032
1080
|
}, [applyWaveformLevels, createAudioFromBlob]);
|
|
1033
|
-
|
|
1081
|
+
/** Waveform bytes are cosmetic, so nothing here may delay playback */
|
|
1082
|
+
const loadWaveformSource = React.useCallback(async (url, cached) => {
|
|
1034
1083
|
var _a;
|
|
1035
|
-
if (
|
|
1084
|
+
if (waveformDataRef.current) {
|
|
1036
1085
|
return;
|
|
1037
1086
|
}
|
|
1038
|
-
if (
|
|
1039
|
-
|
|
1040
|
-
return;
|
|
1041
|
-
}
|
|
1042
|
-
failedSrcRef.current = null;
|
|
1043
|
-
}
|
|
1044
|
-
if (preloadedBlob) {
|
|
1045
|
-
return mountFromBlob(preloadedBlob);
|
|
1046
|
-
}
|
|
1047
|
-
if (!src) {
|
|
1048
|
-
return;
|
|
1087
|
+
if (cached) {
|
|
1088
|
+
return applyWaveformLevels(cached, url);
|
|
1049
1089
|
}
|
|
1050
1090
|
(_a = abortControllerRef.current) === null || _a === void 0 ? void 0 : _a.abort();
|
|
1051
1091
|
const controller = new AbortController();
|
|
1052
1092
|
abortControllerRef.current = controller;
|
|
1053
|
-
setIsLoading(true);
|
|
1054
1093
|
try {
|
|
1055
|
-
const blob = await fetchAudioBlob(
|
|
1056
|
-
if (controller.signal.aborted) {
|
|
1057
|
-
|
|
1094
|
+
const blob = await fetchAudioBlob(url, controller.signal);
|
|
1095
|
+
if (!controller.signal.aborted) {
|
|
1096
|
+
await applyWaveformLevels(blob, url);
|
|
1058
1097
|
}
|
|
1059
|
-
createAudioFromBlob(blob);
|
|
1060
|
-
void applyWaveformLevels(blob);
|
|
1061
1098
|
}
|
|
1062
1099
|
catch (error) {
|
|
1063
1100
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
1064
1101
|
return;
|
|
1065
1102
|
}
|
|
1066
|
-
|
|
1067
|
-
console.warn('[voice-player] Failed to load audio', error);
|
|
1103
|
+
console.warn('[voice-player] Failed to fetch waveform source', error);
|
|
1068
1104
|
}
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1105
|
+
}, [applyWaveformLevels]);
|
|
1106
|
+
const ensureAudioLoaded = React.useCallback(async () => {
|
|
1107
|
+
if (audioRef.current) {
|
|
1108
|
+
return;
|
|
1073
1109
|
}
|
|
1074
|
-
|
|
1110
|
+
/** Only a recording that has not been uploaded yet has no url of its own */
|
|
1111
|
+
if (!src) {
|
|
1112
|
+
return preloadedBlob ? mountFromBlob(preloadedBlob) : undefined;
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Remote files play straight from their url: it streams with range
|
|
1116
|
+
* requests, keeps the user gesture (no await before play) and avoids
|
|
1117
|
+
* blob sources, which some WebKit runtimes refuse to render.
|
|
1118
|
+
*/
|
|
1119
|
+
createAudioFromUrl(src);
|
|
1120
|
+
void loadWaveformSource(src, preloadedBlob);
|
|
1121
|
+
}, [src, preloadedBlob, mountFromBlob, loadWaveformSource, createAudioFromUrl]);
|
|
1075
1122
|
/** Cleanup on src change or unmount */
|
|
1076
1123
|
React.useEffect(() => {
|
|
1077
|
-
failedSrcRef.current = null;
|
|
1078
1124
|
/** Drop the previous waveform so a failed decode cannot leave a foreign one */
|
|
1079
1125
|
setWaveformLevels(waveformDataRef.current || createInitialLevels());
|
|
1080
1126
|
return () => {
|
|
@@ -1092,11 +1138,12 @@ const useAudioBlobLoader = (options) => {
|
|
|
1092
1138
|
setIsLoaded(false);
|
|
1093
1139
|
};
|
|
1094
1140
|
}, [src]);
|
|
1141
|
+
/** A cached recording mounts on its own only while it has no url yet */
|
|
1095
1142
|
React.useEffect(() => {
|
|
1096
|
-
if (preloadedBlob && !audioRef.current) {
|
|
1143
|
+
if (preloadedBlob && !src && !audioRef.current) {
|
|
1097
1144
|
void mountFromBlob(preloadedBlob);
|
|
1098
1145
|
}
|
|
1099
|
-
}, [preloadedBlob, mountFromBlob]);
|
|
1146
|
+
}, [src, preloadedBlob, mountFromBlob]);
|
|
1100
1147
|
React.useEffect(() => {
|
|
1101
1148
|
if (waveformData) {
|
|
1102
1149
|
waveformTokenRef.current += 1;
|
|
@@ -1205,7 +1252,7 @@ const useVoiceMessagePlayer = (options) => {
|
|
|
1205
1252
|
return;
|
|
1206
1253
|
}
|
|
1207
1254
|
audioPlaybackManager.stopOthers(stopThis);
|
|
1208
|
-
void ensureAudioLoaded(
|
|
1255
|
+
void ensureAudioLoaded().then(() => {
|
|
1209
1256
|
const audio = audioRef.current;
|
|
1210
1257
|
if (audio) {
|
|
1211
1258
|
audio.play()
|
|
@@ -1265,7 +1312,7 @@ const useVoiceMessagePlayer = (options) => {
|
|
|
1265
1312
|
.catch(() => setIsPlaying(false));
|
|
1266
1313
|
}
|
|
1267
1314
|
else {
|
|
1268
|
-
void ensureAudioLoaded(
|
|
1315
|
+
void ensureAudioLoaded().then(() => {
|
|
1269
1316
|
const loadedAudio = audioRef.current;
|
|
1270
1317
|
if (loadedAudio) {
|
|
1271
1318
|
audioPlaybackManager.stopOthers(stopThis);
|