@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.mjs CHANGED
@@ -30,10 +30,16 @@ function useRecording(stream, options = {}) {
30
30
  const stopRecordingRef = useRef(null);
31
31
  const stopAckResolveRef = useRef(null);
32
32
  const isStoppingRef = useRef(false);
33
+ const stopPromiseRef = useRef(null);
34
+ const finishStopRecordingRef = useRef(null);
35
+ const isStartingRef = useRef(false);
36
+ const isUnmountedRef = useRef(false);
37
+ const durationRef = useRef(0);
33
38
  const startDurationLoop = useCallback(() => {
34
39
  const tick = () => {
35
40
  if (isRecordingRef.current && !isPausedRef.current && !isStoppingRef.current) {
36
41
  const elapsed = (performance.now() - startTimeRef.current) / 1e3;
42
+ durationRef.current = elapsed;
37
43
  setDuration(elapsed);
38
44
  animationFrameRef.current = requestAnimationFrame(tick);
39
45
  }
@@ -56,11 +62,12 @@ function useRecording(stream, options = {}) {
56
62
  }
57
63
  }, []);
58
64
  const startRecording = useCallback(async () => {
59
- if (isRecordingRef.current) return;
65
+ if (isRecordingRef.current || isStartingRef.current) return false;
60
66
  if (!stream) {
61
67
  setError(new Error("No microphone stream available"));
62
- return;
68
+ return false;
63
69
  }
70
+ isStartingRef.current = true;
64
71
  try {
65
72
  setError(null);
66
73
  const context = getGlobalContext();
@@ -68,6 +75,7 @@ function useRecording(stream, options = {}) {
68
75
  await context.resume();
69
76
  }
70
77
  await loadWorklet();
78
+ if (isUnmountedRef.current) return false;
71
79
  const source = context.createMediaStreamSource(stream);
72
80
  mediaStreamSourceRef.current = source;
73
81
  const detectedChannelCount = stream.getAudioTracks()[0]?.getSettings().channelCount;
@@ -143,17 +151,29 @@ function useRecording(stream, options = {}) {
143
151
  setIsPaused(false);
144
152
  startTimeRef.current = performance.now();
145
153
  startDurationLoop();
154
+ return true;
146
155
  } catch (err) {
147
156
  console.warn("[waveform-playlist] Failed to start recording:", String(err));
148
157
  setError(err instanceof Error ? err : new Error("Failed to start recording"));
158
+ return false;
159
+ } finally {
160
+ isStartingRef.current = false;
149
161
  }
150
162
  }, [stream, channelCount, samplesPerPixel, bits, loadWorklet, startDurationLoop]);
151
163
  const stopRecording = useCallback(async () => {
152
164
  if (!isRecordingRef.current) {
153
165
  return null;
154
166
  }
167
+ if (isStoppingRef.current) {
168
+ return stopPromiseRef.current ?? null;
169
+ }
170
+ isStoppingRef.current = true;
171
+ const stopPromise = finishStopRecordingRef.current();
172
+ stopPromiseRef.current = stopPromise;
173
+ return stopPromise;
174
+ }, []);
175
+ const finishStopRecording = useCallback(async () => {
155
176
  try {
156
- isStoppingRef.current = true;
157
177
  if (animationFrameRef.current !== null) {
158
178
  cancelAnimationFrame(animationFrameRef.current);
159
179
  animationFrameRef.current = null;
@@ -196,6 +216,8 @@ function useRecording(stream, options = {}) {
196
216
  }
197
217
  }
198
218
  node.disconnect();
219
+ workletNodeRef.current = null;
220
+ mediaStreamSourceRef.current = null;
199
221
  }
200
222
  const context = getGlobalContext();
201
223
  const rawContext = context.rawContext;
@@ -208,6 +230,7 @@ function useRecording(stream, options = {}) {
208
230
  }
209
231
  const buffer = createAudioBuffer(rawContext, channelData, rawContext.sampleRate, numChannels);
210
232
  setAudioBuffer(buffer);
233
+ durationRef.current = buffer.duration;
211
234
  setDuration(buffer.duration);
212
235
  return buffer;
213
236
  } catch (err) {
@@ -224,8 +247,9 @@ function useRecording(stream, options = {}) {
224
247
  }
225
248
  }, [channelCount]);
226
249
  stopRecordingRef.current = stopRecording;
250
+ finishStopRecordingRef.current = finishStopRecording;
227
251
  const pauseRecording = useCallback(() => {
228
- if (isRecording && !isPaused) {
252
+ if (isRecordingRef.current && !isPausedRef.current && !isStoppingRef.current) {
229
253
  workletNodeRef.current?.port.postMessage({ command: "pause" });
230
254
  if (animationFrameRef.current !== null) {
231
255
  cancelAnimationFrame(animationFrameRef.current);
@@ -234,18 +258,20 @@ function useRecording(stream, options = {}) {
234
258
  isPausedRef.current = true;
235
259
  setIsPaused(true);
236
260
  }
237
- }, [isRecording, isPaused]);
261
+ }, []);
238
262
  const resumeRecording = useCallback(() => {
239
- if (isRecording && isPaused) {
263
+ if (isRecordingRef.current && isPausedRef.current && !isStoppingRef.current) {
240
264
  workletNodeRef.current?.port.postMessage({ command: "resume" });
241
265
  isPausedRef.current = false;
242
266
  setIsPaused(false);
243
- startTimeRef.current = performance.now() - duration * 1e3;
267
+ startTimeRef.current = performance.now() - durationRef.current * 1e3;
244
268
  startDurationLoop();
245
269
  }
246
- }, [isRecording, isPaused, duration, startDurationLoop]);
270
+ }, [startDurationLoop]);
247
271
  useEffect(() => {
272
+ isUnmountedRef.current = false;
248
273
  return () => {
274
+ isUnmountedRef.current = true;
249
275
  if (audioTrackRef.current && onTrackEndedRef.current) {
250
276
  audioTrackRef.current.removeEventListener("ended", onTrackEndedRef.current);
251
277
  }
@@ -286,21 +312,20 @@ function useRecording(stream, options = {}) {
286
312
  }
287
313
 
288
314
  // src/hooks/useMicrophoneAccess.ts
289
- import { useState as useState2, useEffect as useEffect2, useCallback as useCallback2 } from "react";
315
+ import { useState as useState2, useEffect as useEffect2, useCallback as useCallback2, useRef as useRef2 } from "react";
316
+ import { enumerateMicrophones } from "@waveform-playlist/core";
290
317
  function useMicrophoneAccess() {
291
318
  const [stream, setStream] = useState2(null);
292
319
  const [devices, setDevices] = useState2([]);
293
320
  const [hasPermission, setHasPermission] = useState2(false);
294
321
  const [isLoading, setIsLoading] = useState2(false);
295
322
  const [error, setError] = useState2(null);
323
+ const streamRef = useRef2(null);
324
+ const requestGenerationRef = useRef2(0);
325
+ const isUnmountedRef = useRef2(false);
296
326
  const enumerateDevices = useCallback2(async () => {
297
327
  try {
298
- const allDevices = await navigator.mediaDevices.enumerateDevices();
299
- const audioInputs = allDevices.filter((device) => device.kind === "audioinput").map((device) => ({
300
- deviceId: device.deviceId,
301
- label: device.label || `Microphone ${device.deviceId.slice(0, 8)}`,
302
- groupId: device.groupId
303
- }));
328
+ const { devices: audioInputs } = await enumerateMicrophones();
304
329
  setDevices(audioInputs);
305
330
  } catch (err) {
306
331
  console.error("Failed to enumerate devices:", err);
@@ -309,11 +334,14 @@ function useMicrophoneAccess() {
309
334
  }, []);
310
335
  const requestAccess = useCallback2(
311
336
  async (deviceId, audioConstraints) => {
337
+ const generation = ++requestGenerationRef.current;
312
338
  setIsLoading(true);
313
339
  setError(null);
314
340
  try {
315
- if (stream) {
316
- stream.getTracks().forEach((track) => track.stop());
341
+ if (streamRef.current) {
342
+ streamRef.current.getTracks().forEach((track) => track.stop());
343
+ streamRef.current = null;
344
+ setStream(null);
317
345
  }
318
346
  const audio = {
319
347
  // Recording-optimized defaults: prioritize raw audio quality and low latency
@@ -332,36 +360,50 @@ function useMicrophoneAccess() {
332
360
  video: false
333
361
  };
334
362
  const newStream = await navigator.mediaDevices.getUserMedia(constraints);
363
+ if (generation !== requestGenerationRef.current || isUnmountedRef.current) {
364
+ newStream.getTracks().forEach((track) => track.stop());
365
+ return;
366
+ }
367
+ streamRef.current = newStream;
335
368
  setStream(newStream);
336
369
  setHasPermission(true);
337
370
  await enumerateDevices();
338
371
  } catch (err) {
339
372
  console.error("Failed to access microphone:", err);
340
- setError(err instanceof Error ? err : new Error("Failed to access microphone"));
341
- setHasPermission(false);
373
+ if (generation === requestGenerationRef.current) {
374
+ setError(err instanceof Error ? err : new Error("Failed to access microphone"));
375
+ setHasPermission(false);
376
+ }
342
377
  } finally {
343
- setIsLoading(false);
378
+ if (generation === requestGenerationRef.current) {
379
+ setIsLoading(false);
380
+ }
344
381
  }
345
382
  },
346
- [stream, enumerateDevices]
383
+ [enumerateDevices]
347
384
  );
348
385
  const stopStream = useCallback2(() => {
349
- if (stream) {
350
- stream.getTracks().forEach((track) => track.stop());
386
+ requestGenerationRef.current++;
387
+ if (streamRef.current) {
388
+ streamRef.current.getTracks().forEach((track) => track.stop());
389
+ streamRef.current = null;
351
390
  setStream(null);
352
391
  setHasPermission(false);
353
392
  }
354
- }, [stream]);
393
+ }, []);
355
394
  useEffect2(() => {
395
+ isUnmountedRef.current = false;
356
396
  enumerateDevices();
357
397
  navigator.mediaDevices.addEventListener("devicechange", enumerateDevices);
358
398
  return () => {
359
399
  navigator.mediaDevices.removeEventListener("devicechange", enumerateDevices);
360
- if (stream) {
361
- stream.getTracks().forEach((track) => track.stop());
400
+ isUnmountedRef.current = true;
401
+ if (streamRef.current) {
402
+ streamRef.current.getTracks().forEach((track) => track.stop());
403
+ streamRef.current = null;
362
404
  }
363
405
  };
364
- }, [enumerateDevices, stream]);
406
+ }, [enumerateDevices]);
365
407
  return {
366
408
  stream,
367
409
  devices,
@@ -374,7 +416,7 @@ function useMicrophoneAccess() {
374
416
  }
375
417
 
376
418
  // src/hooks/useMicrophoneLevel.ts
377
- import { useEffect as useEffect3, useState as useState3, useRef as useRef2, useCallback as useCallback3 } from "react";
419
+ import { useEffect as useEffect3, useState as useState3, useRef as useRef3, useCallback as useCallback3 } from "react";
378
420
  import { getGlobalContext as getGlobalContext2 } from "@waveform-playlist/playout";
379
421
  import { gainToNormalized } from "@waveform-playlist/core";
380
422
  import { addMeterWorkletModule } from "@waveform-playlist/worklets";
@@ -385,9 +427,9 @@ function useMicrophoneLevel(stream, options = {}) {
385
427
  const [peakLevels, setPeakLevels] = useState3(() => new Array(channelCount).fill(0));
386
428
  const [rmsLevels, setRmsLevels] = useState3(() => new Array(channelCount).fill(0));
387
429
  const [meterError, setMeterError] = useState3(null);
388
- const workletNodeRef = useRef2(null);
389
- const sourceRef = useRef2(null);
390
- const smoothedPeakRef = useRef2(new Array(channelCount).fill(0));
430
+ const workletNodeRef = useRef3(null);
431
+ const sourceRef = useRef3(null);
432
+ const smoothedPeakRef = useRef3(new Array(channelCount).fill(0));
391
433
  const resetPeak = useCallback3(
392
434
  () => setPeakLevels(new Array(channelCount).fill(0)),
393
435
  [channelCount]
@@ -431,21 +473,23 @@ function useMicrophoneLevel(stream, options = {}) {
431
473
  smoothedPeakRef.current = new Array(actualChannels).fill(0);
432
474
  workletNode.port.onmessage = (event) => {
433
475
  if (!isMounted) return;
434
- const { peak, rms } = event.data;
476
+ const data = event.data;
477
+ const workletChannels = data.length / 2;
435
478
  const smoothed = smoothedPeakRef.current;
436
479
  const peakValues = [];
437
480
  const rmsValues = [];
438
- for (let ch = 0; ch < peak.length; ch++) {
439
- smoothed[ch] = Math.max(peak[ch], (smoothed[ch] ?? 0) * PEAK_DECAY);
481
+ for (let ch = 0; ch < workletChannels; ch++) {
482
+ smoothed[ch] = Math.max(data[ch], (smoothed[ch] ?? 0) * PEAK_DECAY);
440
483
  peakValues.push(gainToNormalized(smoothed[ch]));
441
- rmsValues.push(gainToNormalized(rms[ch]));
484
+ rmsValues.push(gainToNormalized(data[workletChannels + ch]));
442
485
  }
443
- const mirroredPeaks = peak.length < channelCount ? new Array(channelCount).fill(peakValues[0]) : peakValues;
444
- const mirroredRms = peak.length < channelCount ? new Array(channelCount).fill(rmsValues[0]) : rmsValues;
486
+ const mirroredPeaks = workletChannels < channelCount ? new Array(channelCount).fill(peakValues[0]) : peakValues;
487
+ const mirroredRms = workletChannels < channelCount ? new Array(channelCount).fill(rmsValues[0]) : rmsValues;
445
488
  setLevels(mirroredPeaks);
446
489
  setRmsLevels(mirroredRms);
447
490
  setPeakLevels((prev) => mirroredPeaks.map((val, i) => Math.max(prev[i] ?? 0, val)));
448
491
  };
492
+ setMeterError(null);
449
493
  };
450
494
  setupMonitoring().catch((err) => {
451
495
  console.warn("[waveform-playlist] Failed to set up mic level monitoring:", String(err));
@@ -488,8 +532,9 @@ function useMicrophoneLevel(stream, options = {}) {
488
532
  }
489
533
 
490
534
  // src/hooks/useIntegratedRecording.ts
491
- import { useState as useState4, useCallback as useCallback4, useEffect as useEffect4, useRef as useRef3 } from "react";
535
+ import { useState as useState4, useCallback as useCallback4, useEffect as useEffect4, useRef as useRef4 } from "react";
492
536
  import {
537
+ carveClipRange,
493
538
  resolveRecordingOffsetSamples
494
539
  } from "@waveform-playlist/core";
495
540
  import {
@@ -502,11 +547,13 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
502
547
  const [isMonitoring, setIsMonitoring] = useState4(false);
503
548
  const [selectedDevice, setSelectedDevice] = useState4(null);
504
549
  const [hookError, setHookError] = useState4(null);
505
- const recordingStartTimeRef = useRef3(0);
506
- const selectedTrackIdRef = useRef3(selectedTrackId);
550
+ const recordingStartTimeRef = useRef4(0);
551
+ const selectedTrackIdRef = useRef4(selectedTrackId);
507
552
  selectedTrackIdRef.current = selectedTrackId;
508
- const currentTimeRef = useRef3(currentTime);
553
+ const currentTimeRef = useRef4(currentTime);
509
554
  currentTimeRef.current = currentTime;
555
+ const tracksRef = useRef4(tracks);
556
+ tracksRef.current = tracks;
510
557
  const { stream, devices, hasPermission, requestAccess, error: micError } = useMicrophoneAccess();
511
558
  const {
512
559
  level,
@@ -536,7 +583,7 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
536
583
  setHookError(
537
584
  new Error("Cannot start recording: no track selected. Select or create a track first.")
538
585
  );
539
- return;
586
+ return false;
540
587
  }
541
588
  try {
542
589
  setHookError(null);
@@ -545,9 +592,10 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
545
592
  setIsMonitoring(true);
546
593
  }
547
594
  recordingStartTimeRef.current = currentTimeRef.current;
548
- await startRec();
595
+ return await startRec();
549
596
  } catch (err) {
550
597
  setHookError(err instanceof Error ? err : new Error(String(err)));
598
+ return false;
551
599
  }
552
600
  }, [isMonitoring, startRec]);
553
601
  const stopRecording = useCallback4(async () => {
@@ -559,8 +607,9 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
559
607
  return;
560
608
  }
561
609
  const trackId = selectedTrackIdRef.current;
610
+ const currentTracks = tracksRef.current;
562
611
  if (buffer && trackId) {
563
- const selectedTrackIndex = tracks.findIndex((t) => t.id === trackId);
612
+ const selectedTrackIndex = currentTracks.findIndex((t) => t.id === trackId);
564
613
  if (selectedTrackIndex === -1) {
565
614
  const err = new Error(
566
615
  `Recording completed but track "${trackId}" no longer exists. The recorded audio could not be saved.`
@@ -569,16 +618,7 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
569
618
  setHookError(err);
570
619
  return;
571
620
  }
572
- const selectedTrack = tracks[selectedTrackIndex];
573
- const recordStartTimeSamples = Math.floor(recordingStartTimeRef.current * buffer.sampleRate);
574
- let lastClipEndSample = 0;
575
- if (selectedTrack.clips.length > 0) {
576
- const endSamples = selectedTrack.clips.map(
577
- (clip) => clip.startSample + clip.durationSamples
578
- );
579
- lastClipEndSample = Math.max(...endSamples);
580
- }
581
- const startSample = Math.max(recordStartTimeSamples, lastClipEndSample);
621
+ const startSample = Math.floor(recordingStartTimeRef.current * buffer.sampleRate);
582
622
  const audioContext = getGlobalAudioContext();
583
623
  const outputLatency = audioContext.outputLatency ?? 0;
584
624
  const toneContext = getGlobalContext3();
@@ -608,18 +648,21 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
608
648
  gain: 1,
609
649
  name: `Recording ${(/* @__PURE__ */ new Date()).toLocaleTimeString()}`
610
650
  };
611
- const newTracks = tracks.map((track, index) => {
651
+ const newTracks = currentTracks.map((track, index) => {
612
652
  if (index === selectedTrackIndex) {
613
653
  return {
614
654
  ...track,
615
- clips: [...track.clips, newClip]
655
+ clips: [
656
+ ...carveClipRange(track.clips, startSample, startSample + effectiveDuration),
657
+ newClip
658
+ ]
616
659
  };
617
660
  }
618
661
  return track;
619
662
  });
620
663
  setTracks(newTracks);
621
664
  }
622
- }, [tracks, setTracks, stopRec, latencyOffset]);
665
+ }, [setTracks, stopRec, latencyOffset]);
623
666
  useEffect4(() => {
624
667
  if (!hasPermission || devices.length === 0) return;
625
668
  if (selectedDevice === null) {
@@ -631,7 +674,13 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
631
674
  requestAccess(fallbackId, audioConstraints);
632
675
  }
633
676
  }, [hasPermission, devices, selectedDevice, resetPeak, requestAccess, audioConstraints]);
677
+ const isRecordingRef = useRef4(isRecording);
678
+ isRecordingRef.current = isRecording;
634
679
  const requestMicAccess = useCallback4(async () => {
680
+ if (isRecordingRef.current) {
681
+ setHookError(new Error("Cannot request microphone access while recording. Stop first."));
682
+ return;
683
+ }
635
684
  try {
636
685
  setHookError(null);
637
686
  await requestAccess(void 0, audioConstraints);
@@ -643,6 +692,10 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
643
692
  }, [requestAccess, audioConstraints]);
644
693
  const changeDevice = useCallback4(
645
694
  async (deviceId) => {
695
+ if (isRecordingRef.current) {
696
+ setHookError(new Error("Cannot switch microphone while recording. Stop first."));
697
+ return;
698
+ }
646
699
  try {
647
700
  setHookError(null);
648
701
  setSelectedDevice(deviceId);