@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.mjs CHANGED
@@ -75,7 +75,8 @@ function appendPeaks(existingPeaks, newSamples, samplesPerPixel, totalSamplesPro
75
75
  }
76
76
 
77
77
  // src/hooks/useRecording.ts
78
- import { getContext } from "tone";
78
+ import { getGlobalContext } from "@waveform-playlist/playout";
79
+ import { recordingProcessorUrl } from "@waveform-playlist/worklets";
79
80
  function emptyPeaks(bits) {
80
81
  return bits === 8 ? new Int8Array(0) : new Int16Array(0);
81
82
  }
@@ -98,18 +99,29 @@ function useRecording(stream, options = {}) {
98
99
  const startTimeRef = useRef(0);
99
100
  const isRecordingRef = useRef(false);
100
101
  const isPausedRef = useRef(false);
102
+ const startDurationLoop = useCallback(() => {
103
+ const tick = () => {
104
+ if (isRecordingRef.current && !isPausedRef.current) {
105
+ const elapsed = (performance.now() - startTimeRef.current) / 1e3;
106
+ setDuration(elapsed);
107
+ animationFrameRef.current = requestAnimationFrame(tick);
108
+ }
109
+ };
110
+ tick();
111
+ }, []);
101
112
  const loadWorklet = useCallback(async () => {
102
113
  if (workletLoadedRef.current) {
103
114
  return;
104
115
  }
105
116
  try {
106
- const context = getContext();
107
- const workletUrl = new URL("./worklet/recording-processor.worklet.js", import.meta.url).href;
108
- await context.addAudioWorkletModule(workletUrl);
117
+ const context = getGlobalContext();
118
+ const rawCtx = context.rawContext;
119
+ await rawCtx.audioWorklet.addModule(recordingProcessorUrl);
109
120
  workletLoadedRef.current = true;
110
121
  } catch (err) {
111
- console.error("Failed to load AudioWorklet module:", err);
112
- throw new Error("Failed to load recording processor");
122
+ console.warn("[waveform-playlist] Failed to load AudioWorklet module:", String(err));
123
+ const error2 = new Error("Failed to load recording processor: " + String(err));
124
+ throw error2;
113
125
  }
114
126
  }, []);
115
127
  const startRecording = useCallback(async () => {
@@ -119,11 +131,13 @@ function useRecording(stream, options = {}) {
119
131
  }
120
132
  try {
121
133
  setError(null);
122
- const context = getContext();
134
+ const context = getGlobalContext();
123
135
  if (context.state === "suspended") {
124
136
  await context.resume();
125
137
  }
126
138
  await loadWorklet();
139
+ const source = context.createMediaStreamSource(stream);
140
+ mediaStreamSourceRef.current = source;
127
141
  const detectedChannelCount = stream.getAudioTracks()[0]?.getSettings().channelCount;
128
142
  if (detectedChannelCount === void 0) {
129
143
  console.warn(
@@ -131,13 +145,15 @@ function useRecording(stream, options = {}) {
131
145
  );
132
146
  }
133
147
  const streamChannelCount = detectedChannelCount ?? channelCount;
134
- const source = context.createMediaStreamSource(stream);
135
- mediaStreamSourceRef.current = source;
136
148
  const workletNode = context.createAudioWorkletNode("recording-processor", {
137
149
  channelCount: streamChannelCount,
138
150
  channelCountMode: "explicit"
139
151
  });
140
152
  workletNodeRef.current = workletNode;
153
+ workletNode.onprocessorerror = (event) => {
154
+ console.warn("[waveform-playlist] Recording worklet processor error:", String(event));
155
+ setError(new Error("Recording processor encountered an error"));
156
+ };
141
157
  recordedChunksRef.current = Array.from({ length: streamChannelCount }, () => []);
142
158
  totalSamplesRef.current = 0;
143
159
  setPeaks(Array.from({ length: streamChannelCount }, () => emptyPeaks(bits)));
@@ -183,19 +199,12 @@ function useRecording(stream, options = {}) {
183
199
  setIsRecording(true);
184
200
  setIsPaused(false);
185
201
  startTimeRef.current = performance.now();
186
- const updateDuration = () => {
187
- if (isRecordingRef.current && !isPausedRef.current) {
188
- const elapsed = (performance.now() - startTimeRef.current) / 1e3;
189
- setDuration(elapsed);
190
- animationFrameRef.current = requestAnimationFrame(updateDuration);
191
- }
192
- };
193
- updateDuration();
202
+ startDurationLoop();
194
203
  } catch (err) {
195
- console.error("Failed to start recording:", err);
204
+ console.warn("[waveform-playlist] Failed to start recording:", String(err));
196
205
  setError(err instanceof Error ? err : new Error("Failed to start recording"));
197
206
  }
198
- }, [stream, channelCount, samplesPerPixel, bits, loadWorklet]);
207
+ }, [stream, channelCount, samplesPerPixel, bits, loadWorklet, startDurationLoop]);
199
208
  const stopRecording = useCallback(async () => {
200
209
  if (!isRecording) {
201
210
  return null;
@@ -216,10 +225,20 @@ function useRecording(stream, options = {}) {
216
225
  cancelAnimationFrame(animationFrameRef.current);
217
226
  animationFrameRef.current = null;
218
227
  }
219
- const context = getContext();
228
+ const context = getGlobalContext();
220
229
  const rawContext = context.rawContext;
221
230
  const numChannels = recordedChunksRef.current.length || channelCount;
222
231
  const channelData = recordedChunksRef.current.map((chunks) => concatenateAudioData(chunks));
232
+ const totalSamples = channelData[0]?.length ?? 0;
233
+ if (totalSamples === 0) {
234
+ console.warn("[waveform-playlist] Recording stopped with 0 samples captured \u2014 discarding");
235
+ isRecordingRef.current = false;
236
+ isPausedRef.current = false;
237
+ setIsRecording(false);
238
+ setIsPaused(false);
239
+ setLevel(0);
240
+ return null;
241
+ }
223
242
  const buffer = createAudioBuffer(rawContext, channelData, rawContext.sampleRate, numChannels);
224
243
  setAudioBuffer(buffer);
225
244
  setDuration(buffer.duration);
@@ -230,7 +249,7 @@ function useRecording(stream, options = {}) {
230
249
  setLevel(0);
231
250
  return buffer;
232
251
  } catch (err) {
233
- console.error("Failed to stop recording:", err);
252
+ console.warn("[waveform-playlist] Failed to stop recording:", String(err));
234
253
  setError(err instanceof Error ? err : new Error("Failed to stop recording"));
235
254
  return null;
236
255
  }
@@ -250,16 +269,9 @@ function useRecording(stream, options = {}) {
250
269
  isPausedRef.current = false;
251
270
  setIsPaused(false);
252
271
  startTimeRef.current = performance.now() - duration * 1e3;
253
- const updateDuration = () => {
254
- if (isRecordingRef.current && !isPausedRef.current) {
255
- const elapsed = (performance.now() - startTimeRef.current) / 1e3;
256
- setDuration(elapsed);
257
- animationFrameRef.current = requestAnimationFrame(updateDuration);
258
- }
259
- };
260
- updateDuration();
272
+ startDurationLoop();
261
273
  }
262
- }, [isRecording, isPaused, duration]);
274
+ }, [isRecording, isPaused, duration, startDurationLoop]);
263
275
  useEffect(() => {
264
276
  return () => {
265
277
  if (workletNodeRef.current) {
@@ -271,7 +283,11 @@ function useRecording(stream, options = {}) {
271
283
  console.warn("[waveform-playlist] Source disconnect during cleanup:", String(err));
272
284
  }
273
285
  }
274
- workletNodeRef.current.disconnect();
286
+ try {
287
+ workletNodeRef.current.disconnect();
288
+ } catch (err) {
289
+ console.warn("[waveform-playlist] Worklet disconnect during cleanup:", String(err));
290
+ }
275
291
  }
276
292
  if (animationFrameRef.current !== null) {
277
293
  cancelAnimationFrame(animationFrameRef.current);
@@ -363,7 +379,9 @@ function useMicrophoneAccess() {
363
379
  }, [stream]);
364
380
  useEffect2(() => {
365
381
  enumerateDevices();
382
+ navigator.mediaDevices.addEventListener("devicechange", enumerateDevices);
366
383
  return () => {
384
+ navigator.mediaDevices.removeEventListener("devicechange", enumerateDevices);
367
385
  if (stream) {
368
386
  stream.getTracks().forEach((track) => track.stop());
369
387
  }
@@ -381,88 +399,148 @@ function useMicrophoneAccess() {
381
399
  }
382
400
 
383
401
  // src/hooks/useMicrophoneLevel.ts
384
- import { useEffect as useEffect3, useState as useState3, useRef as useRef2 } from "react";
385
- import { Meter, getContext as getContext2, connect } from "tone";
402
+ import { useEffect as useEffect3, useState as useState3, useRef as useRef2, useCallback as useCallback3 } from "react";
403
+ import { getGlobalContext as getGlobalContext2 } from "@waveform-playlist/playout";
404
+ import { gainToNormalized } from "@waveform-playlist/core";
405
+ import { meterProcessorUrl } from "@waveform-playlist/worklets";
406
+ var PEAK_DECAY = 0.98;
386
407
  function useMicrophoneLevel(stream, options = {}) {
387
- const { updateRate = 60, smoothingTimeConstant = 0.8 } = options;
388
- const [level, setLevel] = useState3(0);
389
- const [peakLevel, setPeakLevel] = useState3(0);
390
- const meterRef = useRef2(null);
408
+ const { updateRate = 60, channelCount = 1 } = options;
409
+ const [levels, setLevels] = useState3(() => new Array(channelCount).fill(0));
410
+ const [peakLevels, setPeakLevels] = useState3(() => new Array(channelCount).fill(0));
411
+ const [rmsLevels, setRmsLevels] = useState3(() => new Array(channelCount).fill(0));
412
+ const [meterError, setMeterError] = useState3(null);
413
+ const workletNodeRef = useRef2(null);
391
414
  const sourceRef = useRef2(null);
392
- const animationFrameRef = useRef2(null);
393
- const resetPeak = () => setPeakLevel(0);
415
+ const smoothedPeakRef = useRef2(new Array(channelCount).fill(0));
416
+ const resetPeak = useCallback3(
417
+ () => setPeakLevels(new Array(channelCount).fill(0)),
418
+ [channelCount]
419
+ );
394
420
  useEffect3(() => {
395
421
  if (!stream) {
396
- setLevel(0);
397
- setPeakLevel(0);
422
+ setLevels(new Array(channelCount).fill(0));
423
+ setPeakLevels(new Array(channelCount).fill(0));
424
+ setRmsLevels(new Array(channelCount).fill(0));
425
+ smoothedPeakRef.current = new Array(channelCount).fill(0);
398
426
  return;
399
427
  }
400
428
  let isMounted = true;
401
429
  const setupMonitoring = async () => {
402
430
  if (!isMounted) return;
403
- const context = getContext2();
431
+ const context = getGlobalContext2();
404
432
  if (context.state === "suspended") {
405
433
  await context.resume();
406
434
  }
407
435
  if (!isMounted) return;
408
- const meter = new Meter({ smoothing: smoothingTimeConstant, context });
409
- meterRef.current = meter;
436
+ const trackSettings = stream.getAudioTracks()[0]?.getSettings();
437
+ const actualChannels = trackSettings?.channelCount ?? channelCount;
438
+ const rawCtx = context.rawContext;
439
+ await rawCtx.audioWorklet.addModule(meterProcessorUrl);
440
+ if (!isMounted) return;
441
+ const workletNode = context.createAudioWorkletNode("meter-processor", {
442
+ channelCount: actualChannels,
443
+ channelCountMode: "explicit",
444
+ processorOptions: {
445
+ numberOfChannels: actualChannels,
446
+ updateRate
447
+ }
448
+ });
449
+ workletNodeRef.current = workletNode;
450
+ workletNode.onprocessorerror = (event) => {
451
+ console.warn("[waveform-playlist] Mic meter worklet processor error:", String(event));
452
+ };
410
453
  const source = context.createMediaStreamSource(stream);
411
454
  sourceRef.current = source;
412
- connect(source, meter);
413
- const updateInterval = 1e3 / updateRate;
414
- let lastUpdateTime = 0;
415
- const updateLevel = (timestamp) => {
416
- if (!isMounted || !meterRef.current) return;
417
- if (timestamp - lastUpdateTime >= updateInterval) {
418
- lastUpdateTime = timestamp;
419
- const db = meterRef.current.getValue();
420
- const dbValue = typeof db === "number" ? db : db[0];
421
- const normalized = Math.max(0, Math.min(1, (dbValue + 100) / 100));
422
- setLevel(normalized);
423
- setPeakLevel((prev) => Math.max(prev, normalized));
455
+ source.connect(workletNode);
456
+ smoothedPeakRef.current = new Array(actualChannels).fill(0);
457
+ workletNode.port.onmessage = (event) => {
458
+ if (!isMounted) return;
459
+ const { peak, rms } = event.data;
460
+ const smoothed = smoothedPeakRef.current;
461
+ const peakValues = [];
462
+ const rmsValues = [];
463
+ for (let ch = 0; ch < peak.length; ch++) {
464
+ smoothed[ch] = Math.max(peak[ch], (smoothed[ch] ?? 0) * PEAK_DECAY);
465
+ peakValues.push(gainToNormalized(smoothed[ch]));
466
+ rmsValues.push(gainToNormalized(rms[ch]));
424
467
  }
425
- animationFrameRef.current = requestAnimationFrame(updateLevel);
468
+ const mirroredPeaks = peak.length < channelCount ? new Array(channelCount).fill(peakValues[0]) : peakValues;
469
+ const mirroredRms = peak.length < channelCount ? new Array(channelCount).fill(rmsValues[0]) : rmsValues;
470
+ setLevels(mirroredPeaks);
471
+ setRmsLevels(mirroredRms);
472
+ setPeakLevels((prev) => mirroredPeaks.map((val, i) => Math.max(prev[i] ?? 0, val)));
426
473
  };
427
- animationFrameRef.current = requestAnimationFrame(updateLevel);
428
474
  };
429
- setupMonitoring();
475
+ setupMonitoring().catch((err) => {
476
+ console.warn("[waveform-playlist] Failed to set up mic level monitoring:", String(err));
477
+ if (isMounted) {
478
+ setMeterError(err instanceof Error ? err : new Error(String(err)));
479
+ }
480
+ });
430
481
  return () => {
431
482
  isMounted = false;
432
- if (animationFrameRef.current) {
433
- cancelAnimationFrame(animationFrameRef.current);
434
- animationFrameRef.current = null;
435
- }
436
483
  if (sourceRef.current) {
437
484
  try {
438
485
  sourceRef.current.disconnect();
439
- } catch {
486
+ } catch (err) {
487
+ console.warn("[waveform-playlist] Mic source disconnect during cleanup:", String(err));
440
488
  }
441
489
  sourceRef.current = null;
442
490
  }
443
- if (meterRef.current) {
444
- meterRef.current.dispose();
445
- meterRef.current = null;
491
+ if (workletNodeRef.current) {
492
+ try {
493
+ workletNodeRef.current.disconnect();
494
+ workletNodeRef.current.port.close();
495
+ } catch (err) {
496
+ console.warn("[waveform-playlist] Mic meter disconnect during cleanup:", String(err));
497
+ }
498
+ workletNodeRef.current = null;
446
499
  }
447
500
  };
448
- }, [stream, smoothingTimeConstant, updateRate]);
501
+ }, [stream, updateRate, channelCount]);
502
+ const level = channelCount === 1 ? levels[0] ?? 0 : Math.max(...levels);
503
+ const peakLevel = channelCount === 1 ? peakLevels[0] ?? 0 : Math.max(...peakLevels);
449
504
  return {
450
505
  level,
451
506
  peakLevel,
452
- resetPeak
507
+ resetPeak,
508
+ levels,
509
+ peakLevels,
510
+ rmsLevels,
511
+ error: meterError
453
512
  };
454
513
  }
455
514
 
456
515
  // src/hooks/useIntegratedRecording.ts
457
- import { useState as useState4, useCallback as useCallback3, useEffect as useEffect4 } from "react";
458
- import { resumeGlobalAudioContext } from "@waveform-playlist/playout";
516
+ import { useState as useState4, useCallback as useCallback4, useEffect as useEffect4, useRef as useRef3 } from "react";
517
+ import {
518
+ resumeGlobalAudioContext,
519
+ getGlobalAudioContext,
520
+ getGlobalContext as getGlobalContext3
521
+ } from "@waveform-playlist/playout";
459
522
  function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}) {
460
523
  const { currentTime = 0, audioConstraints, ...recordingOptions } = options;
461
524
  const [isMonitoring, setIsMonitoring] = useState4(false);
462
525
  const [selectedDevice, setSelectedDevice] = useState4(null);
463
526
  const [hookError, setHookError] = useState4(null);
527
+ const recordingStartTimeRef = useRef3(0);
528
+ const selectedTrackIdRef = useRef3(selectedTrackId);
529
+ selectedTrackIdRef.current = selectedTrackId;
530
+ const currentTimeRef = useRef3(currentTime);
531
+ currentTimeRef.current = currentTime;
464
532
  const { stream, devices, hasPermission, requestAccess, error: micError } = useMicrophoneAccess();
465
- const { level, peakLevel } = useMicrophoneLevel(stream);
533
+ const {
534
+ level,
535
+ peakLevel,
536
+ levels,
537
+ peakLevels,
538
+ rmsLevels,
539
+ resetPeak,
540
+ error: meterError
541
+ } = useMicrophoneLevel(stream, {
542
+ channelCount: recordingOptions.channelCount
543
+ });
466
544
  const {
467
545
  isRecording,
468
546
  isPaused,
@@ -475,8 +553,8 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
475
553
  resumeRecording,
476
554
  error: recError
477
555
  } = useRecording(stream, recordingOptions);
478
- const startRecording = useCallback3(async () => {
479
- if (!selectedTrackId) {
556
+ const startRecording = useCallback4(async () => {
557
+ if (!selectedTrackIdRef.current) {
480
558
  setHookError(
481
559
  new Error("Cannot start recording: no track selected. Select or create a track first.")
482
560
  );
@@ -488,12 +566,13 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
488
566
  await resumeGlobalAudioContext();
489
567
  setIsMonitoring(true);
490
568
  }
569
+ recordingStartTimeRef.current = currentTimeRef.current;
491
570
  await startRec();
492
571
  } catch (err) {
493
572
  setHookError(err instanceof Error ? err : new Error(String(err)));
494
573
  }
495
- }, [selectedTrackId, isMonitoring, startRec]);
496
- const stopRecording = useCallback3(async () => {
574
+ }, [isMonitoring, startRec]);
575
+ const stopRecording = useCallback4(async () => {
497
576
  let buffer;
498
577
  try {
499
578
  buffer = await stopRec();
@@ -501,18 +580,19 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
501
580
  setHookError(err instanceof Error ? err : new Error(String(err)));
502
581
  return;
503
582
  }
504
- if (buffer && selectedTrackId) {
505
- const selectedTrackIndex = tracks.findIndex((t) => t.id === selectedTrackId);
583
+ const trackId = selectedTrackIdRef.current;
584
+ if (buffer && trackId) {
585
+ const selectedTrackIndex = tracks.findIndex((t) => t.id === trackId);
506
586
  if (selectedTrackIndex === -1) {
507
587
  const err = new Error(
508
- `Recording completed but track "${selectedTrackId}" no longer exists. The recorded audio could not be saved.`
588
+ `Recording completed but track "${trackId}" no longer exists. The recorded audio could not be saved.`
509
589
  );
510
- console.error(`[waveform-playlist] ${err.message}`);
590
+ console.warn(`[waveform-playlist] ${err.message}`);
511
591
  setHookError(err);
512
592
  return;
513
593
  }
514
594
  const selectedTrack = tracks[selectedTrackIndex];
515
- const currentTimeSamples = Math.floor(currentTime * buffer.sampleRate);
595
+ const recordStartTimeSamples = Math.floor(recordingStartTimeRef.current * buffer.sampleRate);
516
596
  let lastClipEndSample = 0;
517
597
  if (selectedTrack.clips.length > 0) {
518
598
  const endSamples = selectedTrack.clips.map(
@@ -520,13 +600,27 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
520
600
  );
521
601
  lastClipEndSample = Math.max(...endSamples);
522
602
  }
523
- const startSample = Math.max(currentTimeSamples, lastClipEndSample);
603
+ const startSample = Math.max(recordStartTimeSamples, lastClipEndSample);
604
+ const audioContext = getGlobalAudioContext();
605
+ const outputLatency = audioContext.outputLatency ?? 0;
606
+ const toneContext = getGlobalContext3();
607
+ const lookAhead = toneContext.lookAhead ?? 0;
608
+ const totalLatency = outputLatency + lookAhead;
609
+ const latencyOffsetSamples = Math.floor(totalLatency * buffer.sampleRate);
610
+ const effectiveDuration = Math.max(0, buffer.length - latencyOffsetSamples);
611
+ if (effectiveDuration === 0) {
612
+ console.warn(
613
+ "[waveform-playlist] Recording too short for latency compensation \u2014 discarding"
614
+ );
615
+ setHookError(new Error("Recording was too short to save. Try recording for longer."));
616
+ return;
617
+ }
524
618
  const newClip = {
525
619
  id: `clip-${Date.now()}`,
526
620
  audioBuffer: buffer,
527
621
  startSample,
528
- durationSamples: buffer.length,
529
- offsetSamples: 0,
622
+ durationSamples: effectiveDuration,
623
+ offsetSamples: latencyOffsetSamples,
530
624
  sampleRate: buffer.sampleRate,
531
625
  sourceDurationSamples: buffer.length,
532
626
  gain: 1,
@@ -543,13 +637,19 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
543
637
  });
544
638
  setTracks(newTracks);
545
639
  }
546
- }, [selectedTrackId, tracks, setTracks, currentTime, stopRec]);
640
+ }, [tracks, setTracks, stopRec]);
547
641
  useEffect4(() => {
548
- if (hasPermission && devices.length > 0 && selectedDevice === null) {
642
+ if (!hasPermission || devices.length === 0) return;
643
+ if (selectedDevice === null) {
549
644
  setSelectedDevice(devices[0].deviceId);
645
+ } else if (!devices.some((d) => d.deviceId === selectedDevice)) {
646
+ const fallbackId = devices[0].deviceId;
647
+ setSelectedDevice(fallbackId);
648
+ resetPeak();
649
+ requestAccess(fallbackId, audioConstraints);
550
650
  }
551
- }, [hasPermission, devices, selectedDevice]);
552
- const requestMicAccess = useCallback3(async () => {
651
+ }, [hasPermission, devices, selectedDevice, resetPeak, requestAccess, audioConstraints]);
652
+ const requestMicAccess = useCallback4(async () => {
553
653
  try {
554
654
  setHookError(null);
555
655
  await requestAccess(void 0, audioConstraints);
@@ -559,11 +659,12 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
559
659
  setHookError(err instanceof Error ? err : new Error(String(err)));
560
660
  }
561
661
  }, [requestAccess, audioConstraints]);
562
- const changeDevice = useCallback3(
662
+ const changeDevice = useCallback4(
563
663
  async (deviceId) => {
564
664
  try {
565
665
  setHookError(null);
566
666
  setSelectedDevice(deviceId);
667
+ resetPeak();
567
668
  await requestAccess(deviceId, audioConstraints);
568
669
  await resumeGlobalAudioContext();
569
670
  setIsMonitoring(true);
@@ -571,7 +672,7 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
571
672
  setHookError(err instanceof Error ? err : new Error(String(err)));
572
673
  }
573
674
  },
574
- [requestAccess, audioConstraints]
675
+ [requestAccess, audioConstraints, resetPeak]
575
676
  );
576
677
  return {
577
678
  // Recording state
@@ -580,7 +681,10 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
580
681
  duration,
581
682
  level,
582
683
  peakLevel,
583
- error: hookError || micError || recError,
684
+ levels,
685
+ peakLevels,
686
+ rmsLevels,
687
+ error: hookError || micError || meterError || recError,
584
688
  // Microphone state
585
689
  stream,
586
690
  devices,
@@ -597,270 +701,7 @@ function useIntegratedRecording(tracks, setTracks, selectedTrackId, options = {}
597
701
  recordingPeaks: peaks
598
702
  };
599
703
  }
600
-
601
- // src/components/RecordButton.tsx
602
- import styled from "styled-components";
603
- import { jsx, jsxs } from "react/jsx-runtime";
604
- var Button = styled.button`
605
- padding: 0.5rem 1rem;
606
- font-size: 0.875rem;
607
- font-weight: 500;
608
- border: none;
609
- border-radius: 0.25rem;
610
- cursor: pointer;
611
- transition: all 0.2s ease-in-out;
612
- background: ${(props) => props.$isRecording ? "#dc3545" : "#e74c3c"};
613
- color: white;
614
-
615
- &:hover:not(:disabled) {
616
- background: ${(props) => props.$isRecording ? "#c82333" : "#c0392b"};
617
- transform: translateY(-1px);
618
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
619
- }
620
-
621
- &:active:not(:disabled) {
622
- transform: translateY(0);
623
- }
624
-
625
- &:disabled {
626
- opacity: 0.5;
627
- cursor: not-allowed;
628
- }
629
-
630
- &:focus {
631
- outline: none;
632
- box-shadow: 0 0 0 3px rgba(231, 76, 60, 0.3);
633
- }
634
- `;
635
- var RecordingIndicator = styled.span`
636
- display: inline-block;
637
- width: 8px;
638
- height: 8px;
639
- border-radius: 50%;
640
- background: white;
641
- margin-right: 0.5rem;
642
- animation: pulse 1.5s ease-in-out infinite;
643
-
644
- @keyframes pulse {
645
- 0%,
646
- 100% {
647
- opacity: 1;
648
- }
649
- 50% {
650
- opacity: 0.3;
651
- }
652
- }
653
- `;
654
- var RecordButton = ({
655
- isRecording,
656
- onClick,
657
- disabled = false,
658
- className
659
- }) => {
660
- return /* @__PURE__ */ jsxs(
661
- Button,
662
- {
663
- $isRecording: isRecording,
664
- onClick,
665
- disabled,
666
- className,
667
- "aria-label": isRecording ? "Stop recording" : "Start recording",
668
- children: [
669
- isRecording && /* @__PURE__ */ jsx(RecordingIndicator, {}),
670
- isRecording ? "Stop Recording" : "Record"
671
- ]
672
- }
673
- );
674
- };
675
-
676
- // src/components/MicrophoneSelector.tsx
677
- import styled2 from "styled-components";
678
- import { BaseSelect, BaseLabel } from "@waveform-playlist/ui-components";
679
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
680
- var Select = styled2(BaseSelect)`
681
- min-width: 200px;
682
- `;
683
- var Label = styled2(BaseLabel)`
684
- display: flex;
685
- flex-direction: column;
686
- gap: 0.25rem;
687
- `;
688
- var MicrophoneSelector = ({
689
- devices,
690
- selectedDeviceId,
691
- onDeviceChange,
692
- disabled = false,
693
- className
694
- }) => {
695
- const handleChange = (event) => {
696
- onDeviceChange(event.target.value);
697
- };
698
- const currentValue = selectedDeviceId || (devices.length > 0 ? devices[0].deviceId : "");
699
- return /* @__PURE__ */ jsxs2(Label, { className, children: [
700
- "Microphone",
701
- /* @__PURE__ */ jsx2(
702
- Select,
703
- {
704
- value: currentValue,
705
- onChange: handleChange,
706
- disabled: disabled || devices.length === 0,
707
- children: devices.length === 0 ? /* @__PURE__ */ jsx2("option", { value: "", children: "No microphones found" }) : devices.map((device) => /* @__PURE__ */ jsx2("option", { value: device.deviceId, children: device.label }, device.deviceId))
708
- }
709
- )
710
- ] });
711
- };
712
-
713
- // src/components/RecordingIndicator.tsx
714
- import styled3 from "styled-components";
715
- import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
716
- var Container = styled3.div`
717
- display: flex;
718
- align-items: center;
719
- gap: 0.75rem;
720
- padding: 0.5rem 0.75rem;
721
- background: ${(props) => props.$isRecording ? "#fff3cd" : "transparent"};
722
- border-radius: 0.25rem;
723
- transition: background 0.2s ease-in-out;
724
- `;
725
- var Dot = styled3.div`
726
- width: 12px;
727
- height: 12px;
728
- border-radius: 50%;
729
- background: ${(props) => props.$isPaused ? "#ffc107" : "#dc3545"};
730
- opacity: ${(props) => props.$isRecording ? 1 : 0};
731
- transition: opacity 0.2s ease-in-out;
732
-
733
- ${(props) => props.$isRecording && !props.$isPaused && `
734
- animation: blink 1.5s ease-in-out infinite;
735
-
736
- @keyframes blink {
737
- 0%, 100% {
738
- opacity: 1;
739
- }
740
- 50% {
741
- opacity: 0.3;
742
- }
743
- }
744
- `}
745
- `;
746
- var Duration = styled3.span`
747
- font-family: 'Courier New', Monaco, monospace;
748
- font-size: 1rem;
749
- font-weight: 600;
750
- color: #495057;
751
- min-width: 70px;
752
- `;
753
- var Status = styled3.span`
754
- font-size: 0.75rem;
755
- font-weight: 500;
756
- color: ${(props) => props.$isPaused ? "#ffc107" : "#dc3545"};
757
- text-transform: uppercase;
758
- `;
759
- var defaultFormatTime = (seconds) => {
760
- const mins = Math.floor(seconds / 60);
761
- const secs = Math.floor(seconds % 60);
762
- return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
763
- };
764
- var RecordingIndicator2 = ({
765
- isRecording,
766
- isPaused = false,
767
- duration,
768
- formatTime = defaultFormatTime,
769
- className
770
- }) => {
771
- return /* @__PURE__ */ jsxs3(Container, { $isRecording: isRecording, className, children: [
772
- /* @__PURE__ */ jsx3(Dot, { $isRecording: isRecording, $isPaused: isPaused }),
773
- /* @__PURE__ */ jsx3(Duration, { children: formatTime(duration) }),
774
- isRecording && /* @__PURE__ */ jsx3(Status, { $isPaused: isPaused, children: isPaused ? "Paused" : "Recording" })
775
- ] });
776
- };
777
-
778
- // src/components/VUMeter.tsx
779
- import React from "react";
780
- import styled4 from "styled-components";
781
- import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
782
- var MeterContainer = styled4.div`
783
- position: relative;
784
- width: ${(props) => props.$width}px;
785
- height: ${(props) => props.$height}px;
786
- background: #2c3e50;
787
- border-radius: 4px;
788
- overflow: hidden;
789
- box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3);
790
- `;
791
- var getLevelGradient = (level) => {
792
- if (level < 0.6) return "linear-gradient(90deg, #27ae60, #2ecc71)";
793
- if (level < 0.85) return "linear-gradient(90deg, #f39c12, #f1c40f)";
794
- return "linear-gradient(90deg, #c0392b, #e74c3c)";
795
- };
796
- var MeterFill = styled4.div.attrs((props) => ({
797
- style: {
798
- width: `${props.$level * 100}%`,
799
- height: `${props.$height}px`,
800
- background: getLevelGradient(props.$level),
801
- boxShadow: props.$level > 0.01 ? "0 0 8px rgba(255, 255, 255, 0.3)" : "none"
802
- }
803
- }))`
804
- position: absolute;
805
- left: 0;
806
- top: 0;
807
- transition:
808
- width 0.05s ease-out,
809
- background 0.1s ease-out;
810
- `;
811
- var PeakIndicator = styled4.div.attrs((props) => ({
812
- style: {
813
- left: `${props.$peakLevel * 100}%`,
814
- height: `${props.$height}px`
815
- }
816
- }))`
817
- position: absolute;
818
- top: 0;
819
- width: 2px;
820
- background: #ecf0f1;
821
- box-shadow: 0 0 4px rgba(236, 240, 241, 0.8);
822
- transition: left 0.1s ease-out;
823
- `;
824
- var ScaleMarkers = styled4.div`
825
- position: absolute;
826
- top: 0;
827
- left: 0;
828
- width: 100%;
829
- height: ${(props) => props.$height}px;
830
- pointer-events: none;
831
- `;
832
- var ScaleMark = styled4.div`
833
- position: absolute;
834
- left: ${(props) => props.$position}%;
835
- top: 0;
836
- width: 1px;
837
- height: ${(props) => props.$height}px;
838
- background: rgba(255, 255, 255, 0.2);
839
- `;
840
- var VUMeterComponent = ({
841
- level,
842
- peakLevel,
843
- width = 200,
844
- height = 20,
845
- className
846
- }) => {
847
- const clampedLevel = Math.max(0, Math.min(1, level));
848
- const clampedPeak = peakLevel !== void 0 ? Math.max(0, Math.min(1, peakLevel)) : 0;
849
- return /* @__PURE__ */ jsxs4(MeterContainer, { $width: width, $height: height, className, children: [
850
- /* @__PURE__ */ jsx4(MeterFill, { $level: clampedLevel, $height: height }),
851
- peakLevel !== void 0 && clampedPeak > 0 && /* @__PURE__ */ jsx4(PeakIndicator, { $peakLevel: clampedPeak, $height: height }),
852
- /* @__PURE__ */ jsxs4(ScaleMarkers, { $height: height, children: [
853
- /* @__PURE__ */ jsx4(ScaleMark, { $position: 60, $height: height }),
854
- /* @__PURE__ */ jsx4(ScaleMark, { $position: 85, $height: height })
855
- ] })
856
- ] });
857
- };
858
- var VUMeter = React.memo(VUMeterComponent);
859
704
  export {
860
- MicrophoneSelector,
861
- RecordButton,
862
- RecordingIndicator2 as RecordingIndicator,
863
- VUMeter,
864
705
  concatenateAudioData,
865
706
  createAudioBuffer,
866
707
  generatePeaks,