@waveform-playlist/recording 9.5.2 → 10.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/dist/index.js CHANGED
@@ -1,9 +1,7 @@
1
1
  "use strict";
2
- var __create = Object.create;
3
2
  var __defProp = Object.defineProperty;
4
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
- var __getProtoOf = Object.getPrototypeOf;
7
5
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
6
  var __export = (target, all) => {
9
7
  for (var name in all)
@@ -17,23 +15,11 @@ var __copyProps = (to, from, except, desc) => {
17
15
  }
18
16
  return to;
19
17
  };
20
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
- // If the importer is in node compatibility mode or this is not an ESM
22
- // file that has been converted to a CommonJS file using a Babel-
23
- // compatible transform (i.e. "__esModule" has not been set), then set
24
- // "default" to the CommonJS "module.exports" for node compatibility.
25
- isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
- mod
27
- ));
28
18
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
19
 
30
20
  // src/index.ts
31
21
  var src_exports = {};
32
22
  __export(src_exports, {
33
- MicrophoneSelector: () => MicrophoneSelector,
34
- RecordButton: () => RecordButton,
35
- RecordingIndicator: () => RecordingIndicator2,
36
- VUMeter: () => VUMeter,
37
23
  concatenateAudioData: () => concatenateAudioData,
38
24
  createAudioBuffer: () => createAudioBuffer,
39
25
  generatePeaks: () => generatePeaks,
@@ -121,8 +107,8 @@ function appendPeaks(existingPeaks, newSamples, samplesPerPixel, totalSamplesPro
121
107
  }
122
108
 
123
109
  // src/hooks/useRecording.ts
124
- var import_tone = require("tone");
125
- var import_meta = {};
110
+ var import_playout = require("@waveform-playlist/playout");
111
+ var import_worklets = require("@waveform-playlist/worklets");
126
112
  function emptyPeaks(bits) {
127
113
  return bits === 8 ? new Int8Array(0) : new Int16Array(0);
128
114
  }
@@ -145,18 +131,29 @@ function useRecording(stream, options = {}) {
145
131
  const startTimeRef = (0, import_react.useRef)(0);
146
132
  const isRecordingRef = (0, import_react.useRef)(false);
147
133
  const isPausedRef = (0, import_react.useRef)(false);
134
+ const startDurationLoop = (0, import_react.useCallback)(() => {
135
+ const tick = () => {
136
+ if (isRecordingRef.current && !isPausedRef.current) {
137
+ const elapsed = (performance.now() - startTimeRef.current) / 1e3;
138
+ setDuration(elapsed);
139
+ animationFrameRef.current = requestAnimationFrame(tick);
140
+ }
141
+ };
142
+ tick();
143
+ }, []);
148
144
  const loadWorklet = (0, import_react.useCallback)(async () => {
149
145
  if (workletLoadedRef.current) {
150
146
  return;
151
147
  }
152
148
  try {
153
- const context = (0, import_tone.getContext)();
154
- const workletUrl = new URL("./worklet/recording-processor.worklet.js", import_meta.url).href;
155
- await context.addAudioWorkletModule(workletUrl);
149
+ const context = (0, import_playout.getGlobalContext)();
150
+ const rawCtx = context.rawContext;
151
+ await rawCtx.audioWorklet.addModule(import_worklets.recordingProcessorUrl);
156
152
  workletLoadedRef.current = true;
157
153
  } catch (err) {
158
- console.error("Failed to load AudioWorklet module:", err);
159
- throw new Error("Failed to load recording processor");
154
+ console.warn("[waveform-playlist] Failed to load AudioWorklet module:", String(err));
155
+ const error2 = new Error("Failed to load recording processor: " + String(err));
156
+ throw error2;
160
157
  }
161
158
  }, []);
162
159
  const startRecording = (0, import_react.useCallback)(async () => {
@@ -166,11 +163,13 @@ function useRecording(stream, options = {}) {
166
163
  }
167
164
  try {
168
165
  setError(null);
169
- const context = (0, import_tone.getContext)();
166
+ const context = (0, import_playout.getGlobalContext)();
170
167
  if (context.state === "suspended") {
171
168
  await context.resume();
172
169
  }
173
170
  await loadWorklet();
171
+ const source = context.createMediaStreamSource(stream);
172
+ mediaStreamSourceRef.current = source;
174
173
  const detectedChannelCount = stream.getAudioTracks()[0]?.getSettings().channelCount;
175
174
  if (detectedChannelCount === void 0) {
176
175
  console.warn(
@@ -178,13 +177,15 @@ function useRecording(stream, options = {}) {
178
177
  );
179
178
  }
180
179
  const streamChannelCount = detectedChannelCount ?? channelCount;
181
- const source = context.createMediaStreamSource(stream);
182
- mediaStreamSourceRef.current = source;
183
180
  const workletNode = context.createAudioWorkletNode("recording-processor", {
184
181
  channelCount: streamChannelCount,
185
182
  channelCountMode: "explicit"
186
183
  });
187
184
  workletNodeRef.current = workletNode;
185
+ workletNode.onprocessorerror = (event) => {
186
+ console.warn("[waveform-playlist] Recording worklet processor error:", String(event));
187
+ setError(new Error("Recording processor encountered an error"));
188
+ };
188
189
  recordedChunksRef.current = Array.from({ length: streamChannelCount }, () => []);
189
190
  totalSamplesRef.current = 0;
190
191
  setPeaks(Array.from({ length: streamChannelCount }, () => emptyPeaks(bits)));
@@ -230,19 +231,12 @@ function useRecording(stream, options = {}) {
230
231
  setIsRecording(true);
231
232
  setIsPaused(false);
232
233
  startTimeRef.current = performance.now();
233
- const updateDuration = () => {
234
- if (isRecordingRef.current && !isPausedRef.current) {
235
- const elapsed = (performance.now() - startTimeRef.current) / 1e3;
236
- setDuration(elapsed);
237
- animationFrameRef.current = requestAnimationFrame(updateDuration);
238
- }
239
- };
240
- updateDuration();
234
+ startDurationLoop();
241
235
  } catch (err) {
242
- console.error("Failed to start recording:", err);
236
+ console.warn("[waveform-playlist] Failed to start recording:", String(err));
243
237
  setError(err instanceof Error ? err : new Error("Failed to start recording"));
244
238
  }
245
- }, [stream, channelCount, samplesPerPixel, bits, loadWorklet]);
239
+ }, [stream, channelCount, samplesPerPixel, bits, loadWorklet, startDurationLoop]);
246
240
  const stopRecording = (0, import_react.useCallback)(async () => {
247
241
  if (!isRecording) {
248
242
  return null;
@@ -263,10 +257,20 @@ function useRecording(stream, options = {}) {
263
257
  cancelAnimationFrame(animationFrameRef.current);
264
258
  animationFrameRef.current = null;
265
259
  }
266
- const context = (0, import_tone.getContext)();
260
+ const context = (0, import_playout.getGlobalContext)();
267
261
  const rawContext = context.rawContext;
268
262
  const numChannels = recordedChunksRef.current.length || channelCount;
269
263
  const channelData = recordedChunksRef.current.map((chunks) => concatenateAudioData(chunks));
264
+ const totalSamples = channelData[0]?.length ?? 0;
265
+ if (totalSamples === 0) {
266
+ console.warn("[waveform-playlist] Recording stopped with 0 samples captured \u2014 discarding");
267
+ isRecordingRef.current = false;
268
+ isPausedRef.current = false;
269
+ setIsRecording(false);
270
+ setIsPaused(false);
271
+ setLevel(0);
272
+ return null;
273
+ }
270
274
  const buffer = createAudioBuffer(rawContext, channelData, rawContext.sampleRate, numChannels);
271
275
  setAudioBuffer(buffer);
272
276
  setDuration(buffer.duration);
@@ -277,7 +281,7 @@ function useRecording(stream, options = {}) {
277
281
  setLevel(0);
278
282
  return buffer;
279
283
  } catch (err) {
280
- console.error("Failed to stop recording:", err);
284
+ console.warn("[waveform-playlist] Failed to stop recording:", String(err));
281
285
  setError(err instanceof Error ? err : new Error("Failed to stop recording"));
282
286
  return null;
283
287
  }
@@ -297,16 +301,9 @@ function useRecording(stream, options = {}) {
297
301
  isPausedRef.current = false;
298
302
  setIsPaused(false);
299
303
  startTimeRef.current = performance.now() - duration * 1e3;
300
- const updateDuration = () => {
301
- if (isRecordingRef.current && !isPausedRef.current) {
302
- const elapsed = (performance.now() - startTimeRef.current) / 1e3;
303
- setDuration(elapsed);
304
- animationFrameRef.current = requestAnimationFrame(updateDuration);
305
- }
306
- };
307
- updateDuration();
304
+ startDurationLoop();
308
305
  }
309
- }, [isRecording, isPaused, duration]);
306
+ }, [isRecording, isPaused, duration, startDurationLoop]);
310
307
  (0, import_react.useEffect)(() => {
311
308
  return () => {
312
309
  if (workletNodeRef.current) {
@@ -318,7 +315,11 @@ function useRecording(stream, options = {}) {
318
315
  console.warn("[waveform-playlist] Source disconnect during cleanup:", String(err));
319
316
  }
320
317
  }
321
- workletNodeRef.current.disconnect();
318
+ try {
319
+ workletNodeRef.current.disconnect();
320
+ } catch (err) {
321
+ console.warn("[waveform-playlist] Worklet disconnect during cleanup:", String(err));
322
+ }
322
323
  }
323
324
  if (animationFrameRef.current !== null) {
324
325
  cancelAnimationFrame(animationFrameRef.current);
@@ -410,7 +411,9 @@ function useMicrophoneAccess() {
410
411
  }, [stream]);
411
412
  (0, import_react2.useEffect)(() => {
412
413
  enumerateDevices();
414
+ navigator.mediaDevices.addEventListener("devicechange", enumerateDevices);
413
415
  return () => {
416
+ navigator.mediaDevices.removeEventListener("devicechange", enumerateDevices);
414
417
  if (stream) {
415
418
  stream.getTracks().forEach((track) => track.stop());
416
419
  }
@@ -429,87 +432,143 @@ function useMicrophoneAccess() {
429
432
 
430
433
  // src/hooks/useMicrophoneLevel.ts
431
434
  var import_react3 = require("react");
432
- var import_tone2 = require("tone");
435
+ var import_playout2 = require("@waveform-playlist/playout");
436
+ var import_core = require("@waveform-playlist/core");
437
+ var import_worklets2 = require("@waveform-playlist/worklets");
438
+ var PEAK_DECAY = 0.98;
433
439
  function useMicrophoneLevel(stream, options = {}) {
434
- const { updateRate = 60, smoothingTimeConstant = 0.8 } = options;
435
- const [level, setLevel] = (0, import_react3.useState)(0);
436
- const [peakLevel, setPeakLevel] = (0, import_react3.useState)(0);
437
- const meterRef = (0, import_react3.useRef)(null);
440
+ const { updateRate = 60, channelCount = 1 } = options;
441
+ const [levels, setLevels] = (0, import_react3.useState)(() => new Array(channelCount).fill(0));
442
+ const [peakLevels, setPeakLevels] = (0, import_react3.useState)(() => new Array(channelCount).fill(0));
443
+ const [rmsLevels, setRmsLevels] = (0, import_react3.useState)(() => new Array(channelCount).fill(0));
444
+ const [meterError, setMeterError] = (0, import_react3.useState)(null);
445
+ const workletNodeRef = (0, import_react3.useRef)(null);
438
446
  const sourceRef = (0, import_react3.useRef)(null);
439
- const animationFrameRef = (0, import_react3.useRef)(null);
440
- const resetPeak = () => setPeakLevel(0);
447
+ const smoothedPeakRef = (0, import_react3.useRef)(new Array(channelCount).fill(0));
448
+ const resetPeak = (0, import_react3.useCallback)(
449
+ () => setPeakLevels(new Array(channelCount).fill(0)),
450
+ [channelCount]
451
+ );
441
452
  (0, import_react3.useEffect)(() => {
442
453
  if (!stream) {
443
- setLevel(0);
444
- setPeakLevel(0);
454
+ setLevels(new Array(channelCount).fill(0));
455
+ setPeakLevels(new Array(channelCount).fill(0));
456
+ setRmsLevels(new Array(channelCount).fill(0));
457
+ smoothedPeakRef.current = new Array(channelCount).fill(0);
445
458
  return;
446
459
  }
447
460
  let isMounted = true;
448
461
  const setupMonitoring = async () => {
449
462
  if (!isMounted) return;
450
- const context = (0, import_tone2.getContext)();
463
+ const context = (0, import_playout2.getGlobalContext)();
451
464
  if (context.state === "suspended") {
452
465
  await context.resume();
453
466
  }
454
467
  if (!isMounted) return;
455
- const meter = new import_tone2.Meter({ smoothing: smoothingTimeConstant, context });
456
- meterRef.current = meter;
468
+ const trackSettings = stream.getAudioTracks()[0]?.getSettings();
469
+ const actualChannels = trackSettings?.channelCount ?? channelCount;
470
+ const rawCtx = context.rawContext;
471
+ await rawCtx.audioWorklet.addModule(import_worklets2.meterProcessorUrl);
472
+ if (!isMounted) return;
473
+ const workletNode = context.createAudioWorkletNode("meter-processor", {
474
+ channelCount: actualChannels,
475
+ channelCountMode: "explicit",
476
+ processorOptions: {
477
+ numberOfChannels: actualChannels,
478
+ updateRate
479
+ }
480
+ });
481
+ workletNodeRef.current = workletNode;
482
+ workletNode.onprocessorerror = (event) => {
483
+ console.warn("[waveform-playlist] Mic meter worklet processor error:", String(event));
484
+ };
457
485
  const source = context.createMediaStreamSource(stream);
458
486
  sourceRef.current = source;
459
- (0, import_tone2.connect)(source, meter);
460
- const updateInterval = 1e3 / updateRate;
461
- let lastUpdateTime = 0;
462
- const updateLevel = (timestamp) => {
463
- if (!isMounted || !meterRef.current) return;
464
- if (timestamp - lastUpdateTime >= updateInterval) {
465
- lastUpdateTime = timestamp;
466
- const db = meterRef.current.getValue();
467
- const dbValue = typeof db === "number" ? db : db[0];
468
- const normalized = Math.max(0, Math.min(1, (dbValue + 100) / 100));
469
- setLevel(normalized);
470
- setPeakLevel((prev) => Math.max(prev, normalized));
487
+ source.connect(workletNode);
488
+ smoothedPeakRef.current = new Array(actualChannels).fill(0);
489
+ workletNode.port.onmessage = (event) => {
490
+ if (!isMounted) return;
491
+ const { peak, rms } = event.data;
492
+ const smoothed = smoothedPeakRef.current;
493
+ const peakValues = [];
494
+ const rmsValues = [];
495
+ for (let ch = 0; ch < peak.length; ch++) {
496
+ smoothed[ch] = Math.max(peak[ch], (smoothed[ch] ?? 0) * PEAK_DECAY);
497
+ peakValues.push((0, import_core.gainToNormalized)(smoothed[ch]));
498
+ rmsValues.push((0, import_core.gainToNormalized)(rms[ch]));
471
499
  }
472
- animationFrameRef.current = requestAnimationFrame(updateLevel);
500
+ const mirroredPeaks = peak.length < channelCount ? new Array(channelCount).fill(peakValues[0]) : peakValues;
501
+ const mirroredRms = peak.length < channelCount ? new Array(channelCount).fill(rmsValues[0]) : rmsValues;
502
+ setLevels(mirroredPeaks);
503
+ setRmsLevels(mirroredRms);
504
+ setPeakLevels((prev) => mirroredPeaks.map((val, i) => Math.max(prev[i] ?? 0, val)));
473
505
  };
474
- animationFrameRef.current = requestAnimationFrame(updateLevel);
475
506
  };
476
- setupMonitoring();
507
+ setupMonitoring().catch((err) => {
508
+ console.warn("[waveform-playlist] Failed to set up mic level monitoring:", String(err));
509
+ if (isMounted) {
510
+ setMeterError(err instanceof Error ? err : new Error(String(err)));
511
+ }
512
+ });
477
513
  return () => {
478
514
  isMounted = false;
479
- if (animationFrameRef.current) {
480
- cancelAnimationFrame(animationFrameRef.current);
481
- animationFrameRef.current = null;
482
- }
483
515
  if (sourceRef.current) {
484
516
  try {
485
517
  sourceRef.current.disconnect();
486
- } catch {
518
+ } catch (err) {
519
+ console.warn("[waveform-playlist] Mic source disconnect during cleanup:", String(err));
487
520
  }
488
521
  sourceRef.current = null;
489
522
  }
490
- if (meterRef.current) {
491
- meterRef.current.dispose();
492
- meterRef.current = null;
523
+ if (workletNodeRef.current) {
524
+ try {
525
+ workletNodeRef.current.disconnect();
526
+ workletNodeRef.current.port.close();
527
+ } catch (err) {
528
+ console.warn("[waveform-playlist] Mic meter disconnect during cleanup:", String(err));
529
+ }
530
+ workletNodeRef.current = null;
493
531
  }
494
532
  };
495
- }, [stream, smoothingTimeConstant, updateRate]);
533
+ }, [stream, updateRate, channelCount]);
534
+ const level = channelCount === 1 ? levels[0] ?? 0 : Math.max(...levels);
535
+ const peakLevel = channelCount === 1 ? peakLevels[0] ?? 0 : Math.max(...peakLevels);
496
536
  return {
497
537
  level,
498
538
  peakLevel,
499
- resetPeak
539
+ resetPeak,
540
+ levels,
541
+ peakLevels,
542
+ rmsLevels,
543
+ error: meterError
500
544
  };
501
545
  }
502
546
 
503
547
  // src/hooks/useIntegratedRecording.ts
504
548
  var import_react4 = require("react");
505
- var import_playout = require("@waveform-playlist/playout");
549
+ var import_playout3 = require("@waveform-playlist/playout");
506
550
  function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}) {
507
551
  const { currentTime = 0, audioConstraints, ...recordingOptions } = options;
508
552
  const [isMonitoring, setIsMonitoring] = (0, import_react4.useState)(false);
509
553
  const [selectedDevice, setSelectedDevice] = (0, import_react4.useState)(null);
510
554
  const [hookError, setHookError] = (0, import_react4.useState)(null);
555
+ const recordingStartTimeRef = (0, import_react4.useRef)(0);
556
+ const selectedTrackIdRef = (0, import_react4.useRef)(selectedTrackId);
557
+ selectedTrackIdRef.current = selectedTrackId;
558
+ const currentTimeRef = (0, import_react4.useRef)(currentTime);
559
+ currentTimeRef.current = currentTime;
511
560
  const { stream, devices, hasPermission, requestAccess, error: micError } = useMicrophoneAccess();
512
- const { level, peakLevel } = useMicrophoneLevel(stream);
561
+ const {
562
+ level,
563
+ peakLevel,
564
+ levels,
565
+ peakLevels,
566
+ rmsLevels,
567
+ resetPeak,
568
+ error: meterError
569
+ } = useMicrophoneLevel(stream, {
570
+ channelCount: recordingOptions.channelCount
571
+ });
513
572
  const {
514
573
  isRecording,
515
574
  isPaused,
@@ -523,7 +582,7 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
523
582
  error: recError
524
583
  } = useRecording(stream, recordingOptions);
525
584
  const startRecording = (0, import_react4.useCallback)(async () => {
526
- if (!selectedTrackId) {
585
+ if (!selectedTrackIdRef.current) {
527
586
  setHookError(
528
587
  new Error("Cannot start recording: no track selected. Select or create a track first.")
529
588
  );
@@ -532,14 +591,15 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
532
591
  try {
533
592
  setHookError(null);
534
593
  if (!isMonitoring) {
535
- await (0, import_playout.resumeGlobalAudioContext)();
594
+ await (0, import_playout3.resumeGlobalAudioContext)();
536
595
  setIsMonitoring(true);
537
596
  }
597
+ recordingStartTimeRef.current = currentTimeRef.current;
538
598
  await startRec();
539
599
  } catch (err) {
540
600
  setHookError(err instanceof Error ? err : new Error(String(err)));
541
601
  }
542
- }, [selectedTrackId, isMonitoring, startRec]);
602
+ }, [isMonitoring, startRec]);
543
603
  const stopRecording = (0, import_react4.useCallback)(async () => {
544
604
  let buffer;
545
605
  try {
@@ -548,18 +608,19 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
548
608
  setHookError(err instanceof Error ? err : new Error(String(err)));
549
609
  return;
550
610
  }
551
- if (buffer && selectedTrackId) {
552
- const selectedTrackIndex = tracks.findIndex((t) => t.id === selectedTrackId);
611
+ const trackId = selectedTrackIdRef.current;
612
+ if (buffer && trackId) {
613
+ const selectedTrackIndex = tracks.findIndex((t) => t.id === trackId);
553
614
  if (selectedTrackIndex === -1) {
554
615
  const err = new Error(
555
- `Recording completed but track "${selectedTrackId}" no longer exists. The recorded audio could not be saved.`
616
+ `Recording completed but track "${trackId}" no longer exists. The recorded audio could not be saved.`
556
617
  );
557
- console.error(`[waveform-playlist] ${err.message}`);
618
+ console.warn(`[waveform-playlist] ${err.message}`);
558
619
  setHookError(err);
559
620
  return;
560
621
  }
561
622
  const selectedTrack = tracks[selectedTrackIndex];
562
- const currentTimeSamples = Math.floor(currentTime * buffer.sampleRate);
623
+ const recordStartTimeSamples = Math.floor(recordingStartTimeRef.current * buffer.sampleRate);
563
624
  let lastClipEndSample = 0;
564
625
  if (selectedTrack.clips.length > 0) {
565
626
  const endSamples = selectedTrack.clips.map(
@@ -567,13 +628,27 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
567
628
  );
568
629
  lastClipEndSample = Math.max(...endSamples);
569
630
  }
570
- const startSample = Math.max(currentTimeSamples, lastClipEndSample);
631
+ const startSample = Math.max(recordStartTimeSamples, lastClipEndSample);
632
+ const audioContext = (0, import_playout3.getGlobalAudioContext)();
633
+ const outputLatency = audioContext.outputLatency ?? 0;
634
+ const toneContext = (0, import_playout3.getGlobalContext)();
635
+ const lookAhead = toneContext.lookAhead ?? 0;
636
+ const totalLatency = outputLatency + lookAhead;
637
+ const latencyOffsetSamples = Math.floor(totalLatency * buffer.sampleRate);
638
+ const effectiveDuration = Math.max(0, buffer.length - latencyOffsetSamples);
639
+ if (effectiveDuration === 0) {
640
+ console.warn(
641
+ "[waveform-playlist] Recording too short for latency compensation \u2014 discarding"
642
+ );
643
+ setHookError(new Error("Recording was too short to save. Try recording for longer."));
644
+ return;
645
+ }
571
646
  const newClip = {
572
647
  id: `clip-${Date.now()}`,
573
648
  audioBuffer: buffer,
574
649
  startSample,
575
- durationSamples: buffer.length,
576
- offsetSamples: 0,
650
+ durationSamples: effectiveDuration,
651
+ offsetSamples: latencyOffsetSamples,
577
652
  sampleRate: buffer.sampleRate,
578
653
  sourceDurationSamples: buffer.length,
579
654
  gain: 1,
@@ -590,17 +665,23 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
590
665
  });
591
666
  setTracks(newTracks);
592
667
  }
593
- }, [selectedTrackId, tracks, setTracks, currentTime, stopRec]);
668
+ }, [tracks, setTracks, stopRec]);
594
669
  (0, import_react4.useEffect)(() => {
595
- if (hasPermission && devices.length > 0 && selectedDevice === null) {
670
+ if (!hasPermission || devices.length === 0) return;
671
+ if (selectedDevice === null) {
596
672
  setSelectedDevice(devices[0].deviceId);
673
+ } else if (!devices.some((d) => d.deviceId === selectedDevice)) {
674
+ const fallbackId = devices[0].deviceId;
675
+ setSelectedDevice(fallbackId);
676
+ resetPeak();
677
+ requestAccess(fallbackId, audioConstraints);
597
678
  }
598
- }, [hasPermission, devices, selectedDevice]);
679
+ }, [hasPermission, devices, selectedDevice, resetPeak, requestAccess, audioConstraints]);
599
680
  const requestMicAccess = (0, import_react4.useCallback)(async () => {
600
681
  try {
601
682
  setHookError(null);
602
683
  await requestAccess(void 0, audioConstraints);
603
- await (0, import_playout.resumeGlobalAudioContext)();
684
+ await (0, import_playout3.resumeGlobalAudioContext)();
604
685
  setIsMonitoring(true);
605
686
  } catch (err) {
606
687
  setHookError(err instanceof Error ? err : new Error(String(err)));
@@ -611,14 +692,15 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
611
692
  try {
612
693
  setHookError(null);
613
694
  setSelectedDevice(deviceId);
695
+ resetPeak();
614
696
  await requestAccess(deviceId, audioConstraints);
615
- await (0, import_playout.resumeGlobalAudioContext)();
697
+ await (0, import_playout3.resumeGlobalAudioContext)();
616
698
  setIsMonitoring(true);
617
699
  } catch (err) {
618
700
  setHookError(err instanceof Error ? err : new Error(String(err)));
619
701
  }
620
702
  },
621
- [requestAccess, audioConstraints]
703
+ [requestAccess, audioConstraints, resetPeak]
622
704
  );
623
705
  return {
624
706
  // Recording state
@@ -627,7 +709,10 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
627
709
  duration,
628
710
  level,
629
711
  peakLevel,
630
- error: hookError || micError || recError,
712
+ levels,
713
+ peakLevels,
714
+ rmsLevels,
715
+ error: hookError || micError || meterError || recError,
631
716
  // Microphone state
632
717
  stream,
633
718
  devices,
@@ -644,271 +729,8 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
644
729
  recordingPeaks: peaks
645
730
  };
646
731
  }
647
-
648
- // src/components/RecordButton.tsx
649
- var import_styled_components = __toESM(require("styled-components"));
650
- var import_jsx_runtime = require("react/jsx-runtime");
651
- var Button = import_styled_components.default.button`
652
- padding: 0.5rem 1rem;
653
- font-size: 0.875rem;
654
- font-weight: 500;
655
- border: none;
656
- border-radius: 0.25rem;
657
- cursor: pointer;
658
- transition: all 0.2s ease-in-out;
659
- background: ${(props) => props.$isRecording ? "#dc3545" : "#e74c3c"};
660
- color: white;
661
-
662
- &:hover:not(:disabled) {
663
- background: ${(props) => props.$isRecording ? "#c82333" : "#c0392b"};
664
- transform: translateY(-1px);
665
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
666
- }
667
-
668
- &:active:not(:disabled) {
669
- transform: translateY(0);
670
- }
671
-
672
- &:disabled {
673
- opacity: 0.5;
674
- cursor: not-allowed;
675
- }
676
-
677
- &:focus {
678
- outline: none;
679
- box-shadow: 0 0 0 3px rgba(231, 76, 60, 0.3);
680
- }
681
- `;
682
- var RecordingIndicator = import_styled_components.default.span`
683
- display: inline-block;
684
- width: 8px;
685
- height: 8px;
686
- border-radius: 50%;
687
- background: white;
688
- margin-right: 0.5rem;
689
- animation: pulse 1.5s ease-in-out infinite;
690
-
691
- @keyframes pulse {
692
- 0%,
693
- 100% {
694
- opacity: 1;
695
- }
696
- 50% {
697
- opacity: 0.3;
698
- }
699
- }
700
- `;
701
- var RecordButton = ({
702
- isRecording,
703
- onClick,
704
- disabled = false,
705
- className
706
- }) => {
707
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
708
- Button,
709
- {
710
- $isRecording: isRecording,
711
- onClick,
712
- disabled,
713
- className,
714
- "aria-label": isRecording ? "Stop recording" : "Start recording",
715
- children: [
716
- isRecording && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(RecordingIndicator, {}),
717
- isRecording ? "Stop Recording" : "Record"
718
- ]
719
- }
720
- );
721
- };
722
-
723
- // src/components/MicrophoneSelector.tsx
724
- var import_styled_components2 = __toESM(require("styled-components"));
725
- var import_ui_components = require("@waveform-playlist/ui-components");
726
- var import_jsx_runtime2 = require("react/jsx-runtime");
727
- var Select = (0, import_styled_components2.default)(import_ui_components.BaseSelect)`
728
- min-width: 200px;
729
- `;
730
- var Label = (0, import_styled_components2.default)(import_ui_components.BaseLabel)`
731
- display: flex;
732
- flex-direction: column;
733
- gap: 0.25rem;
734
- `;
735
- var MicrophoneSelector = ({
736
- devices,
737
- selectedDeviceId,
738
- onDeviceChange,
739
- disabled = false,
740
- className
741
- }) => {
742
- const handleChange = (event) => {
743
- onDeviceChange(event.target.value);
744
- };
745
- const currentValue = selectedDeviceId || (devices.length > 0 ? devices[0].deviceId : "");
746
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(Label, { className, children: [
747
- "Microphone",
748
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
749
- Select,
750
- {
751
- value: currentValue,
752
- onChange: handleChange,
753
- disabled: disabled || devices.length === 0,
754
- children: devices.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("option", { value: "", children: "No microphones found" }) : devices.map((device) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("option", { value: device.deviceId, children: device.label }, device.deviceId))
755
- }
756
- )
757
- ] });
758
- };
759
-
760
- // src/components/RecordingIndicator.tsx
761
- var import_styled_components3 = __toESM(require("styled-components"));
762
- var import_jsx_runtime3 = require("react/jsx-runtime");
763
- var Container = import_styled_components3.default.div`
764
- display: flex;
765
- align-items: center;
766
- gap: 0.75rem;
767
- padding: 0.5rem 0.75rem;
768
- background: ${(props) => props.$isRecording ? "#fff3cd" : "transparent"};
769
- border-radius: 0.25rem;
770
- transition: background 0.2s ease-in-out;
771
- `;
772
- var Dot = import_styled_components3.default.div`
773
- width: 12px;
774
- height: 12px;
775
- border-radius: 50%;
776
- background: ${(props) => props.$isPaused ? "#ffc107" : "#dc3545"};
777
- opacity: ${(props) => props.$isRecording ? 1 : 0};
778
- transition: opacity 0.2s ease-in-out;
779
-
780
- ${(props) => props.$isRecording && !props.$isPaused && `
781
- animation: blink 1.5s ease-in-out infinite;
782
-
783
- @keyframes blink {
784
- 0%, 100% {
785
- opacity: 1;
786
- }
787
- 50% {
788
- opacity: 0.3;
789
- }
790
- }
791
- `}
792
- `;
793
- var Duration = import_styled_components3.default.span`
794
- font-family: 'Courier New', Monaco, monospace;
795
- font-size: 1rem;
796
- font-weight: 600;
797
- color: #495057;
798
- min-width: 70px;
799
- `;
800
- var Status = import_styled_components3.default.span`
801
- font-size: 0.75rem;
802
- font-weight: 500;
803
- color: ${(props) => props.$isPaused ? "#ffc107" : "#dc3545"};
804
- text-transform: uppercase;
805
- `;
806
- var defaultFormatTime = (seconds) => {
807
- const mins = Math.floor(seconds / 60);
808
- const secs = Math.floor(seconds % 60);
809
- return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
810
- };
811
- var RecordingIndicator2 = ({
812
- isRecording,
813
- isPaused = false,
814
- duration,
815
- formatTime = defaultFormatTime,
816
- className
817
- }) => {
818
- return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(Container, { $isRecording: isRecording, className, children: [
819
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Dot, { $isRecording: isRecording, $isPaused: isPaused }),
820
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Duration, { children: formatTime(duration) }),
821
- isRecording && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Status, { $isPaused: isPaused, children: isPaused ? "Paused" : "Recording" })
822
- ] });
823
- };
824
-
825
- // src/components/VUMeter.tsx
826
- var import_react5 = __toESM(require("react"));
827
- var import_styled_components4 = __toESM(require("styled-components"));
828
- var import_jsx_runtime4 = require("react/jsx-runtime");
829
- var MeterContainer = import_styled_components4.default.div`
830
- position: relative;
831
- width: ${(props) => props.$width}px;
832
- height: ${(props) => props.$height}px;
833
- background: #2c3e50;
834
- border-radius: 4px;
835
- overflow: hidden;
836
- box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3);
837
- `;
838
- var getLevelGradient = (level) => {
839
- if (level < 0.6) return "linear-gradient(90deg, #27ae60, #2ecc71)";
840
- if (level < 0.85) return "linear-gradient(90deg, #f39c12, #f1c40f)";
841
- return "linear-gradient(90deg, #c0392b, #e74c3c)";
842
- };
843
- var MeterFill = import_styled_components4.default.div.attrs((props) => ({
844
- style: {
845
- width: `${props.$level * 100}%`,
846
- height: `${props.$height}px`,
847
- background: getLevelGradient(props.$level),
848
- boxShadow: props.$level > 0.01 ? "0 0 8px rgba(255, 255, 255, 0.3)" : "none"
849
- }
850
- }))`
851
- position: absolute;
852
- left: 0;
853
- top: 0;
854
- transition:
855
- width 0.05s ease-out,
856
- background 0.1s ease-out;
857
- `;
858
- var PeakIndicator = import_styled_components4.default.div.attrs((props) => ({
859
- style: {
860
- left: `${props.$peakLevel * 100}%`,
861
- height: `${props.$height}px`
862
- }
863
- }))`
864
- position: absolute;
865
- top: 0;
866
- width: 2px;
867
- background: #ecf0f1;
868
- box-shadow: 0 0 4px rgba(236, 240, 241, 0.8);
869
- transition: left 0.1s ease-out;
870
- `;
871
- var ScaleMarkers = import_styled_components4.default.div`
872
- position: absolute;
873
- top: 0;
874
- left: 0;
875
- width: 100%;
876
- height: ${(props) => props.$height}px;
877
- pointer-events: none;
878
- `;
879
- var ScaleMark = import_styled_components4.default.div`
880
- position: absolute;
881
- left: ${(props) => props.$position}%;
882
- top: 0;
883
- width: 1px;
884
- height: ${(props) => props.$height}px;
885
- background: rgba(255, 255, 255, 0.2);
886
- `;
887
- var VUMeterComponent = ({
888
- level,
889
- peakLevel,
890
- width = 200,
891
- height = 20,
892
- className
893
- }) => {
894
- const clampedLevel = Math.max(0, Math.min(1, level));
895
- const clampedPeak = peakLevel !== void 0 ? Math.max(0, Math.min(1, peakLevel)) : 0;
896
- return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(MeterContainer, { $width: width, $height: height, className, children: [
897
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(MeterFill, { $level: clampedLevel, $height: height }),
898
- peakLevel !== void 0 && clampedPeak > 0 && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(PeakIndicator, { $peakLevel: clampedPeak, $height: height }),
899
- /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(ScaleMarkers, { $height: height, children: [
900
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ScaleMark, { $position: 60, $height: height }),
901
- /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ScaleMark, { $position: 85, $height: height })
902
- ] })
903
- ] });
904
- };
905
- var VUMeter = import_react5.default.memo(VUMeterComponent);
906
732
  // Annotate the CommonJS export names for ESM import in node:
907
733
  0 && (module.exports = {
908
- MicrophoneSelector,
909
- RecordButton,
910
- RecordingIndicator,
911
- VUMeter,
912
734
  concatenateAudioData,
913
735
  createAudioBuffer,
914
736
  generatePeaks,