@waveform-playlist/recording 12.2.0 → 13.0.0
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 +153 -139
- package/dist/index.d.mts +35 -10
- package/dist/index.d.ts +35 -10
- package/dist/index.js +104 -52
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +110 -57
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -59,10 +59,16 @@ function useRecording(stream, options = {}) {
|
|
|
59
59
|
const stopRecordingRef = (0, import_react.useRef)(null);
|
|
60
60
|
const stopAckResolveRef = (0, import_react.useRef)(null);
|
|
61
61
|
const isStoppingRef = (0, import_react.useRef)(false);
|
|
62
|
+
const stopPromiseRef = (0, import_react.useRef)(null);
|
|
63
|
+
const finishStopRecordingRef = (0, import_react.useRef)(null);
|
|
64
|
+
const isStartingRef = (0, import_react.useRef)(false);
|
|
65
|
+
const isUnmountedRef = (0, import_react.useRef)(false);
|
|
66
|
+
const durationRef = (0, import_react.useRef)(0);
|
|
62
67
|
const startDurationLoop = (0, import_react.useCallback)(() => {
|
|
63
68
|
const tick = () => {
|
|
64
69
|
if (isRecordingRef.current && !isPausedRef.current && !isStoppingRef.current) {
|
|
65
70
|
const elapsed = (performance.now() - startTimeRef.current) / 1e3;
|
|
71
|
+
durationRef.current = elapsed;
|
|
66
72
|
setDuration(elapsed);
|
|
67
73
|
animationFrameRef.current = requestAnimationFrame(tick);
|
|
68
74
|
}
|
|
@@ -85,11 +91,12 @@ function useRecording(stream, options = {}) {
|
|
|
85
91
|
}
|
|
86
92
|
}, []);
|
|
87
93
|
const startRecording = (0, import_react.useCallback)(async () => {
|
|
88
|
-
if (isRecordingRef.current) return;
|
|
94
|
+
if (isRecordingRef.current || isStartingRef.current) return false;
|
|
89
95
|
if (!stream) {
|
|
90
96
|
setError(new Error("No microphone stream available"));
|
|
91
|
-
return;
|
|
97
|
+
return false;
|
|
92
98
|
}
|
|
99
|
+
isStartingRef.current = true;
|
|
93
100
|
try {
|
|
94
101
|
setError(null);
|
|
95
102
|
const context = (0, import_playout.getGlobalContext)();
|
|
@@ -97,6 +104,7 @@ function useRecording(stream, options = {}) {
|
|
|
97
104
|
await context.resume();
|
|
98
105
|
}
|
|
99
106
|
await loadWorklet();
|
|
107
|
+
if (isUnmountedRef.current) return false;
|
|
100
108
|
const source = context.createMediaStreamSource(stream);
|
|
101
109
|
mediaStreamSourceRef.current = source;
|
|
102
110
|
const detectedChannelCount = stream.getAudioTracks()[0]?.getSettings().channelCount;
|
|
@@ -172,17 +180,29 @@ function useRecording(stream, options = {}) {
|
|
|
172
180
|
setIsPaused(false);
|
|
173
181
|
startTimeRef.current = performance.now();
|
|
174
182
|
startDurationLoop();
|
|
183
|
+
return true;
|
|
175
184
|
} catch (err) {
|
|
176
185
|
console.warn("[waveform-playlist] Failed to start recording:", String(err));
|
|
177
186
|
setError(err instanceof Error ? err : new Error("Failed to start recording"));
|
|
187
|
+
return false;
|
|
188
|
+
} finally {
|
|
189
|
+
isStartingRef.current = false;
|
|
178
190
|
}
|
|
179
191
|
}, [stream, channelCount, samplesPerPixel, bits, loadWorklet, startDurationLoop]);
|
|
180
192
|
const stopRecording = (0, import_react.useCallback)(async () => {
|
|
181
193
|
if (!isRecordingRef.current) {
|
|
182
194
|
return null;
|
|
183
195
|
}
|
|
196
|
+
if (isStoppingRef.current) {
|
|
197
|
+
return stopPromiseRef.current ?? null;
|
|
198
|
+
}
|
|
199
|
+
isStoppingRef.current = true;
|
|
200
|
+
const stopPromise = finishStopRecordingRef.current();
|
|
201
|
+
stopPromiseRef.current = stopPromise;
|
|
202
|
+
return stopPromise;
|
|
203
|
+
}, []);
|
|
204
|
+
const finishStopRecording = (0, import_react.useCallback)(async () => {
|
|
184
205
|
try {
|
|
185
|
-
isStoppingRef.current = true;
|
|
186
206
|
if (animationFrameRef.current !== null) {
|
|
187
207
|
cancelAnimationFrame(animationFrameRef.current);
|
|
188
208
|
animationFrameRef.current = null;
|
|
@@ -225,6 +245,8 @@ function useRecording(stream, options = {}) {
|
|
|
225
245
|
}
|
|
226
246
|
}
|
|
227
247
|
node.disconnect();
|
|
248
|
+
workletNodeRef.current = null;
|
|
249
|
+
mediaStreamSourceRef.current = null;
|
|
228
250
|
}
|
|
229
251
|
const context = (0, import_playout.getGlobalContext)();
|
|
230
252
|
const rawContext = context.rawContext;
|
|
@@ -237,6 +259,7 @@ function useRecording(stream, options = {}) {
|
|
|
237
259
|
}
|
|
238
260
|
const buffer = (0, import_core.createAudioBuffer)(rawContext, channelData, rawContext.sampleRate, numChannels);
|
|
239
261
|
setAudioBuffer(buffer);
|
|
262
|
+
durationRef.current = buffer.duration;
|
|
240
263
|
setDuration(buffer.duration);
|
|
241
264
|
return buffer;
|
|
242
265
|
} catch (err) {
|
|
@@ -253,8 +276,9 @@ function useRecording(stream, options = {}) {
|
|
|
253
276
|
}
|
|
254
277
|
}, [channelCount]);
|
|
255
278
|
stopRecordingRef.current = stopRecording;
|
|
279
|
+
finishStopRecordingRef.current = finishStopRecording;
|
|
256
280
|
const pauseRecording = (0, import_react.useCallback)(() => {
|
|
257
|
-
if (
|
|
281
|
+
if (isRecordingRef.current && !isPausedRef.current && !isStoppingRef.current) {
|
|
258
282
|
workletNodeRef.current?.port.postMessage({ command: "pause" });
|
|
259
283
|
if (animationFrameRef.current !== null) {
|
|
260
284
|
cancelAnimationFrame(animationFrameRef.current);
|
|
@@ -263,18 +287,20 @@ function useRecording(stream, options = {}) {
|
|
|
263
287
|
isPausedRef.current = true;
|
|
264
288
|
setIsPaused(true);
|
|
265
289
|
}
|
|
266
|
-
}, [
|
|
290
|
+
}, []);
|
|
267
291
|
const resumeRecording = (0, import_react.useCallback)(() => {
|
|
268
|
-
if (
|
|
292
|
+
if (isRecordingRef.current && isPausedRef.current && !isStoppingRef.current) {
|
|
269
293
|
workletNodeRef.current?.port.postMessage({ command: "resume" });
|
|
270
294
|
isPausedRef.current = false;
|
|
271
295
|
setIsPaused(false);
|
|
272
|
-
startTimeRef.current = performance.now() -
|
|
296
|
+
startTimeRef.current = performance.now() - durationRef.current * 1e3;
|
|
273
297
|
startDurationLoop();
|
|
274
298
|
}
|
|
275
|
-
}, [
|
|
299
|
+
}, [startDurationLoop]);
|
|
276
300
|
(0, import_react.useEffect)(() => {
|
|
301
|
+
isUnmountedRef.current = false;
|
|
277
302
|
return () => {
|
|
303
|
+
isUnmountedRef.current = true;
|
|
278
304
|
if (audioTrackRef.current && onTrackEndedRef.current) {
|
|
279
305
|
audioTrackRef.current.removeEventListener("ended", onTrackEndedRef.current);
|
|
280
306
|
}
|
|
@@ -316,20 +342,19 @@ function useRecording(stream, options = {}) {
|
|
|
316
342
|
|
|
317
343
|
// src/hooks/useMicrophoneAccess.ts
|
|
318
344
|
var import_react2 = require("react");
|
|
345
|
+
var import_core2 = require("@waveform-playlist/core");
|
|
319
346
|
function useMicrophoneAccess() {
|
|
320
347
|
const [stream, setStream] = (0, import_react2.useState)(null);
|
|
321
348
|
const [devices, setDevices] = (0, import_react2.useState)([]);
|
|
322
349
|
const [hasPermission, setHasPermission] = (0, import_react2.useState)(false);
|
|
323
350
|
const [isLoading, setIsLoading] = (0, import_react2.useState)(false);
|
|
324
351
|
const [error, setError] = (0, import_react2.useState)(null);
|
|
352
|
+
const streamRef = (0, import_react2.useRef)(null);
|
|
353
|
+
const requestGenerationRef = (0, import_react2.useRef)(0);
|
|
354
|
+
const isUnmountedRef = (0, import_react2.useRef)(false);
|
|
325
355
|
const enumerateDevices = (0, import_react2.useCallback)(async () => {
|
|
326
356
|
try {
|
|
327
|
-
const
|
|
328
|
-
const audioInputs = allDevices.filter((device) => device.kind === "audioinput").map((device) => ({
|
|
329
|
-
deviceId: device.deviceId,
|
|
330
|
-
label: device.label || `Microphone ${device.deviceId.slice(0, 8)}`,
|
|
331
|
-
groupId: device.groupId
|
|
332
|
-
}));
|
|
357
|
+
const { devices: audioInputs } = await (0, import_core2.enumerateMicrophones)();
|
|
333
358
|
setDevices(audioInputs);
|
|
334
359
|
} catch (err) {
|
|
335
360
|
console.error("Failed to enumerate devices:", err);
|
|
@@ -338,11 +363,14 @@ function useMicrophoneAccess() {
|
|
|
338
363
|
}, []);
|
|
339
364
|
const requestAccess = (0, import_react2.useCallback)(
|
|
340
365
|
async (deviceId, audioConstraints) => {
|
|
366
|
+
const generation = ++requestGenerationRef.current;
|
|
341
367
|
setIsLoading(true);
|
|
342
368
|
setError(null);
|
|
343
369
|
try {
|
|
344
|
-
if (
|
|
345
|
-
|
|
370
|
+
if (streamRef.current) {
|
|
371
|
+
streamRef.current.getTracks().forEach((track) => track.stop());
|
|
372
|
+
streamRef.current = null;
|
|
373
|
+
setStream(null);
|
|
346
374
|
}
|
|
347
375
|
const audio = {
|
|
348
376
|
// Recording-optimized defaults: prioritize raw audio quality and low latency
|
|
@@ -361,36 +389,50 @@ function useMicrophoneAccess() {
|
|
|
361
389
|
video: false
|
|
362
390
|
};
|
|
363
391
|
const newStream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
392
|
+
if (generation !== requestGenerationRef.current || isUnmountedRef.current) {
|
|
393
|
+
newStream.getTracks().forEach((track) => track.stop());
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
streamRef.current = newStream;
|
|
364
397
|
setStream(newStream);
|
|
365
398
|
setHasPermission(true);
|
|
366
399
|
await enumerateDevices();
|
|
367
400
|
} catch (err) {
|
|
368
401
|
console.error("Failed to access microphone:", err);
|
|
369
|
-
|
|
370
|
-
|
|
402
|
+
if (generation === requestGenerationRef.current) {
|
|
403
|
+
setError(err instanceof Error ? err : new Error("Failed to access microphone"));
|
|
404
|
+
setHasPermission(false);
|
|
405
|
+
}
|
|
371
406
|
} finally {
|
|
372
|
-
|
|
407
|
+
if (generation === requestGenerationRef.current) {
|
|
408
|
+
setIsLoading(false);
|
|
409
|
+
}
|
|
373
410
|
}
|
|
374
411
|
},
|
|
375
|
-
[
|
|
412
|
+
[enumerateDevices]
|
|
376
413
|
);
|
|
377
414
|
const stopStream = (0, import_react2.useCallback)(() => {
|
|
378
|
-
|
|
379
|
-
|
|
415
|
+
requestGenerationRef.current++;
|
|
416
|
+
if (streamRef.current) {
|
|
417
|
+
streamRef.current.getTracks().forEach((track) => track.stop());
|
|
418
|
+
streamRef.current = null;
|
|
380
419
|
setStream(null);
|
|
381
420
|
setHasPermission(false);
|
|
382
421
|
}
|
|
383
|
-
}, [
|
|
422
|
+
}, []);
|
|
384
423
|
(0, import_react2.useEffect)(() => {
|
|
424
|
+
isUnmountedRef.current = false;
|
|
385
425
|
enumerateDevices();
|
|
386
426
|
navigator.mediaDevices.addEventListener("devicechange", enumerateDevices);
|
|
387
427
|
return () => {
|
|
388
428
|
navigator.mediaDevices.removeEventListener("devicechange", enumerateDevices);
|
|
389
|
-
|
|
390
|
-
|
|
429
|
+
isUnmountedRef.current = true;
|
|
430
|
+
if (streamRef.current) {
|
|
431
|
+
streamRef.current.getTracks().forEach((track) => track.stop());
|
|
432
|
+
streamRef.current = null;
|
|
391
433
|
}
|
|
392
434
|
};
|
|
393
|
-
}, [enumerateDevices
|
|
435
|
+
}, [enumerateDevices]);
|
|
394
436
|
return {
|
|
395
437
|
stream,
|
|
396
438
|
devices,
|
|
@@ -405,7 +447,7 @@ function useMicrophoneAccess() {
|
|
|
405
447
|
// src/hooks/useMicrophoneLevel.ts
|
|
406
448
|
var import_react3 = require("react");
|
|
407
449
|
var import_playout2 = require("@waveform-playlist/playout");
|
|
408
|
-
var
|
|
450
|
+
var import_core3 = require("@waveform-playlist/core");
|
|
409
451
|
var import_worklets2 = require("@waveform-playlist/worklets");
|
|
410
452
|
var PEAK_DECAY = 0.98;
|
|
411
453
|
function useMicrophoneLevel(stream, options = {}) {
|
|
@@ -460,21 +502,23 @@ function useMicrophoneLevel(stream, options = {}) {
|
|
|
460
502
|
smoothedPeakRef.current = new Array(actualChannels).fill(0);
|
|
461
503
|
workletNode.port.onmessage = (event) => {
|
|
462
504
|
if (!isMounted) return;
|
|
463
|
-
const
|
|
505
|
+
const data = event.data;
|
|
506
|
+
const workletChannels = data.length / 2;
|
|
464
507
|
const smoothed = smoothedPeakRef.current;
|
|
465
508
|
const peakValues = [];
|
|
466
509
|
const rmsValues = [];
|
|
467
|
-
for (let ch = 0; ch <
|
|
468
|
-
smoothed[ch] = Math.max(
|
|
469
|
-
peakValues.push((0,
|
|
470
|
-
rmsValues.push((0,
|
|
510
|
+
for (let ch = 0; ch < workletChannels; ch++) {
|
|
511
|
+
smoothed[ch] = Math.max(data[ch], (smoothed[ch] ?? 0) * PEAK_DECAY);
|
|
512
|
+
peakValues.push((0, import_core3.gainToNormalized)(smoothed[ch]));
|
|
513
|
+
rmsValues.push((0, import_core3.gainToNormalized)(data[workletChannels + ch]));
|
|
471
514
|
}
|
|
472
|
-
const mirroredPeaks =
|
|
473
|
-
const mirroredRms =
|
|
515
|
+
const mirroredPeaks = workletChannels < channelCount ? new Array(channelCount).fill(peakValues[0]) : peakValues;
|
|
516
|
+
const mirroredRms = workletChannels < channelCount ? new Array(channelCount).fill(rmsValues[0]) : rmsValues;
|
|
474
517
|
setLevels(mirroredPeaks);
|
|
475
518
|
setRmsLevels(mirroredRms);
|
|
476
519
|
setPeakLevels((prev) => mirroredPeaks.map((val, i) => Math.max(prev[i] ?? 0, val)));
|
|
477
520
|
};
|
|
521
|
+
setMeterError(null);
|
|
478
522
|
};
|
|
479
523
|
setupMonitoring().catch((err) => {
|
|
480
524
|
console.warn("[waveform-playlist] Failed to set up mic level monitoring:", String(err));
|
|
@@ -518,7 +562,7 @@ function useMicrophoneLevel(stream, options = {}) {
|
|
|
518
562
|
|
|
519
563
|
// src/hooks/useIntegratedRecording.ts
|
|
520
564
|
var import_react4 = require("react");
|
|
521
|
-
var
|
|
565
|
+
var import_core4 = require("@waveform-playlist/core");
|
|
522
566
|
var import_playout3 = require("@waveform-playlist/playout");
|
|
523
567
|
function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}) {
|
|
524
568
|
const { currentTime = 0, audioConstraints, latencyOffset, ...recordingOptions } = options;
|
|
@@ -530,6 +574,8 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
|
|
|
530
574
|
selectedTrackIdRef.current = selectedTrackId;
|
|
531
575
|
const currentTimeRef = (0, import_react4.useRef)(currentTime);
|
|
532
576
|
currentTimeRef.current = currentTime;
|
|
577
|
+
const tracksRef = (0, import_react4.useRef)(tracks);
|
|
578
|
+
tracksRef.current = tracks;
|
|
533
579
|
const { stream, devices, hasPermission, requestAccess, error: micError } = useMicrophoneAccess();
|
|
534
580
|
const {
|
|
535
581
|
level,
|
|
@@ -559,7 +605,7 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
|
|
|
559
605
|
setHookError(
|
|
560
606
|
new Error("Cannot start recording: no track selected. Select or create a track first.")
|
|
561
607
|
);
|
|
562
|
-
return;
|
|
608
|
+
return false;
|
|
563
609
|
}
|
|
564
610
|
try {
|
|
565
611
|
setHookError(null);
|
|
@@ -568,9 +614,10 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
|
|
|
568
614
|
setIsMonitoring(true);
|
|
569
615
|
}
|
|
570
616
|
recordingStartTimeRef.current = currentTimeRef.current;
|
|
571
|
-
await startRec();
|
|
617
|
+
return await startRec();
|
|
572
618
|
} catch (err) {
|
|
573
619
|
setHookError(err instanceof Error ? err : new Error(String(err)));
|
|
620
|
+
return false;
|
|
574
621
|
}
|
|
575
622
|
}, [isMonitoring, startRec]);
|
|
576
623
|
const stopRecording = (0, import_react4.useCallback)(async () => {
|
|
@@ -582,8 +629,9 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
|
|
|
582
629
|
return;
|
|
583
630
|
}
|
|
584
631
|
const trackId = selectedTrackIdRef.current;
|
|
632
|
+
const currentTracks = tracksRef.current;
|
|
585
633
|
if (buffer && trackId) {
|
|
586
|
-
const selectedTrackIndex =
|
|
634
|
+
const selectedTrackIndex = currentTracks.findIndex((t) => t.id === trackId);
|
|
587
635
|
if (selectedTrackIndex === -1) {
|
|
588
636
|
const err = new Error(
|
|
589
637
|
`Recording completed but track "${trackId}" no longer exists. The recorded audio could not be saved.`
|
|
@@ -592,21 +640,12 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
|
|
|
592
640
|
setHookError(err);
|
|
593
641
|
return;
|
|
594
642
|
}
|
|
595
|
-
const
|
|
596
|
-
const recordStartTimeSamples = Math.floor(recordingStartTimeRef.current * buffer.sampleRate);
|
|
597
|
-
let lastClipEndSample = 0;
|
|
598
|
-
if (selectedTrack.clips.length > 0) {
|
|
599
|
-
const endSamples = selectedTrack.clips.map(
|
|
600
|
-
(clip) => clip.startSample + clip.durationSamples
|
|
601
|
-
);
|
|
602
|
-
lastClipEndSample = Math.max(...endSamples);
|
|
603
|
-
}
|
|
604
|
-
const startSample = Math.max(recordStartTimeSamples, lastClipEndSample);
|
|
643
|
+
const startSample = Math.floor(recordingStartTimeRef.current * buffer.sampleRate);
|
|
605
644
|
const audioContext = (0, import_playout3.getGlobalAudioContext)();
|
|
606
645
|
const outputLatency = audioContext.outputLatency ?? 0;
|
|
607
646
|
const toneContext = (0, import_playout3.getGlobalContext)();
|
|
608
647
|
const lookAhead = toneContext.lookAhead ?? 0;
|
|
609
|
-
const latencyOffsetSamples = (0,
|
|
648
|
+
const latencyOffsetSamples = (0, import_core4.resolveRecordingOffsetSamples)({
|
|
610
649
|
overrideSeconds: latencyOffset,
|
|
611
650
|
outputLatency,
|
|
612
651
|
lookAhead,
|
|
@@ -631,18 +670,21 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
|
|
|
631
670
|
gain: 1,
|
|
632
671
|
name: `Recording ${(/* @__PURE__ */ new Date()).toLocaleTimeString()}`
|
|
633
672
|
};
|
|
634
|
-
const newTracks =
|
|
673
|
+
const newTracks = currentTracks.map((track, index) => {
|
|
635
674
|
if (index === selectedTrackIndex) {
|
|
636
675
|
return {
|
|
637
676
|
...track,
|
|
638
|
-
clips: [
|
|
677
|
+
clips: [
|
|
678
|
+
...(0, import_core4.carveClipRange)(track.clips, startSample, startSample + effectiveDuration),
|
|
679
|
+
newClip
|
|
680
|
+
]
|
|
639
681
|
};
|
|
640
682
|
}
|
|
641
683
|
return track;
|
|
642
684
|
});
|
|
643
685
|
setTracks(newTracks);
|
|
644
686
|
}
|
|
645
|
-
}, [
|
|
687
|
+
}, [setTracks, stopRec, latencyOffset]);
|
|
646
688
|
(0, import_react4.useEffect)(() => {
|
|
647
689
|
if (!hasPermission || devices.length === 0) return;
|
|
648
690
|
if (selectedDevice === null) {
|
|
@@ -654,7 +696,13 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
|
|
|
654
696
|
requestAccess(fallbackId, audioConstraints);
|
|
655
697
|
}
|
|
656
698
|
}, [hasPermission, devices, selectedDevice, resetPeak, requestAccess, audioConstraints]);
|
|
699
|
+
const isRecordingRef = (0, import_react4.useRef)(isRecording);
|
|
700
|
+
isRecordingRef.current = isRecording;
|
|
657
701
|
const requestMicAccess = (0, import_react4.useCallback)(async () => {
|
|
702
|
+
if (isRecordingRef.current) {
|
|
703
|
+
setHookError(new Error("Cannot request microphone access while recording. Stop first."));
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
658
706
|
try {
|
|
659
707
|
setHookError(null);
|
|
660
708
|
await requestAccess(void 0, audioConstraints);
|
|
@@ -666,6 +714,10 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
|
|
|
666
714
|
}, [requestAccess, audioConstraints]);
|
|
667
715
|
const changeDevice = (0, import_react4.useCallback)(
|
|
668
716
|
async (deviceId) => {
|
|
717
|
+
if (isRecordingRef.current) {
|
|
718
|
+
setHookError(new Error("Cannot switch microphone while recording. Stop first."));
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
669
721
|
try {
|
|
670
722
|
setHookError(null);
|
|
671
723
|
setSelectedDevice(deviceId);
|