@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.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
|
|
125
|
-
var
|
|
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,
|
|
154
|
-
const
|
|
155
|
-
await
|
|
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.
|
|
159
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
435
|
-
const [
|
|
436
|
-
const [
|
|
437
|
-
const
|
|
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
|
|
440
|
-
const resetPeak = ()
|
|
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
|
-
|
|
444
|
-
|
|
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,
|
|
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
|
|
456
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
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 (
|
|
491
|
-
|
|
492
|
-
|
|
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,
|
|
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
|
|
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 {
|
|
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 (!
|
|
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,
|
|
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
|
-
}, [
|
|
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
|
-
|
|
552
|
-
|
|
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 "${
|
|
616
|
+
`Recording completed but track "${trackId}" no longer exists. The recorded audio could not be saved.`
|
|
556
617
|
);
|
|
557
|
-
console.
|
|
618
|
+
console.warn(`[waveform-playlist] ${err.message}`);
|
|
558
619
|
setHookError(err);
|
|
559
620
|
return;
|
|
560
621
|
}
|
|
561
622
|
const selectedTrack = tracks[selectedTrackIndex];
|
|
562
|
-
const
|
|
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(
|
|
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:
|
|
576
|
-
offsetSamples:
|
|
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
|
-
}, [
|
|
668
|
+
}, [tracks, setTracks, stopRec]);
|
|
594
669
|
(0, import_react4.useEffect)(() => {
|
|
595
|
-
if (hasPermission
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|