@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.d.mts +41 -91
- package/dist/index.d.ts +41 -91
- package/dist/index.js +188 -366
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +195 -354
- package/dist/index.mjs.map +1 -1
- package/package.json +13 -12
- package/LICENSE.md +0 -21
- package/dist/worklet/recording-processor.worklet.js +0 -76
- package/dist/worklet/recording-processor.worklet.js.map +0 -1
- package/dist/worklet/recording-processor.worklet.mjs +0 -74
- package/dist/worklet/recording-processor.worklet.mjs.map +0 -1
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 {
|
|
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 =
|
|
107
|
-
const
|
|
108
|
-
await
|
|
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.
|
|
112
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
388
|
-
const [
|
|
389
|
-
const [
|
|
390
|
-
const
|
|
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
|
|
393
|
-
const resetPeak = (
|
|
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
|
-
|
|
397
|
-
|
|
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 =
|
|
431
|
+
const context = getGlobalContext2();
|
|
404
432
|
if (context.state === "suspended") {
|
|
405
433
|
await context.resume();
|
|
406
434
|
}
|
|
407
435
|
if (!isMounted) return;
|
|
408
|
-
const
|
|
409
|
-
|
|
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(
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
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 (
|
|
444
|
-
|
|
445
|
-
|
|
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,
|
|
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
|
|
458
|
-
import {
|
|
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 {
|
|
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 =
|
|
479
|
-
if (!
|
|
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
|
-
}, [
|
|
496
|
-
const stopRecording =
|
|
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
|
-
|
|
505
|
-
|
|
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 "${
|
|
588
|
+
`Recording completed but track "${trackId}" no longer exists. The recorded audio could not be saved.`
|
|
509
589
|
);
|
|
510
|
-
console.
|
|
590
|
+
console.warn(`[waveform-playlist] ${err.message}`);
|
|
511
591
|
setHookError(err);
|
|
512
592
|
return;
|
|
513
593
|
}
|
|
514
594
|
const selectedTrack = tracks[selectedTrackIndex];
|
|
515
|
-
const
|
|
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(
|
|
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:
|
|
529
|
-
offsetSamples:
|
|
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
|
-
}, [
|
|
640
|
+
}, [tracks, setTracks, stopRec]);
|
|
547
641
|
useEffect4(() => {
|
|
548
|
-
if (hasPermission
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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,
|