@waveform-playlist/recording 12.2.0 → 13.0.1

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/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 (isRecording && !isPaused) {
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
- }, [isRecording, isPaused]);
290
+ }, []);
267
291
  const resumeRecording = (0, import_react.useCallback)(() => {
268
- if (isRecording && isPaused) {
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() - duration * 1e3;
296
+ startTimeRef.current = performance.now() - durationRef.current * 1e3;
273
297
  startDurationLoop();
274
298
  }
275
- }, [isRecording, isPaused, duration, startDurationLoop]);
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 allDevices = await navigator.mediaDevices.enumerateDevices();
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 (stream) {
345
- stream.getTracks().forEach((track) => track.stop());
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
- setError(err instanceof Error ? err : new Error("Failed to access microphone"));
370
- setHasPermission(false);
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
- setIsLoading(false);
407
+ if (generation === requestGenerationRef.current) {
408
+ setIsLoading(false);
409
+ }
373
410
  }
374
411
  },
375
- [stream, enumerateDevices]
412
+ [enumerateDevices]
376
413
  );
377
414
  const stopStream = (0, import_react2.useCallback)(() => {
378
- if (stream) {
379
- stream.getTracks().forEach((track) => track.stop());
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
- }, [stream]);
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
- if (stream) {
390
- stream.getTracks().forEach((track) => track.stop());
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, stream]);
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 import_core2 = require("@waveform-playlist/core");
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 { peak, rms } = event.data;
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 < peak.length; ch++) {
468
- smoothed[ch] = Math.max(peak[ch], (smoothed[ch] ?? 0) * PEAK_DECAY);
469
- peakValues.push((0, import_core2.gainToNormalized)(smoothed[ch]));
470
- rmsValues.push((0, import_core2.gainToNormalized)(rms[ch]));
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 = peak.length < channelCount ? new Array(channelCount).fill(peakValues[0]) : peakValues;
473
- const mirroredRms = peak.length < channelCount ? new Array(channelCount).fill(rmsValues[0]) : rmsValues;
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 import_core3 = require("@waveform-playlist/core");
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 = tracks.findIndex((t) => t.id === trackId);
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 selectedTrack = tracks[selectedTrackIndex];
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, import_core3.resolveRecordingOffsetSamples)({
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 = tracks.map((track, index) => {
673
+ const newTracks = currentTracks.map((track, index) => {
635
674
  if (index === selectedTrackIndex) {
636
675
  return {
637
676
  ...track,
638
- clips: [...track.clips, newClip]
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
- }, [tracks, setTracks, stopRec, latencyOffset]);
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);