audio-chunkify 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,746 @@
1
+ // src/constants.ts
2
+ var DEFAULT_AUDIO_CONSTANTS = {
3
+ /** FFT size for the AnalyserNode (must be a power of 2). Affects frequency resolution. */
4
+ ANALYSIS_BUFFER_SIZE: 2048,
5
+ /** Silence threshold in dB used for chunk splitting (-Infinity to 0). */
6
+ SILENCE_THRESHOLD: -50,
7
+ /** Maximum chunk duration in seconds before a forced split. */
8
+ MAX_CHUNK_DURATION: 300,
9
+ /** Minimum chunk duration in seconds before a silence-based split is allowed. */
10
+ MIN_CHUNK_DURATION: 30,
11
+ /** Minimum continuous silence duration in seconds required to trigger a split. */
12
+ MIN_SILENCE_DURATION: 1
13
+ };
14
+
15
+ // src/utils/audioCodec.ts
16
+ var PREFERRED_MIME_TYPES = [
17
+ "audio/webm;codecs=opus",
18
+ "audio/webm",
19
+ "audio/mp4;codecs=mp4a.40.2",
20
+ "audio/mp4",
21
+ "audio/ogg;codecs=opus",
22
+ "audio/ogg"
23
+ ];
24
+ function getCompatibleMimeType() {
25
+ if (typeof MediaRecorder === "undefined") {
26
+ return null;
27
+ }
28
+ for (const mimeType of PREFERRED_MIME_TYPES) {
29
+ if (MediaRecorder.isTypeSupported(mimeType)) {
30
+ return mimeType;
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+ function getFileExtensionFromMimeType(mimeType) {
36
+ if (!mimeType) return "webm";
37
+ if (mimeType.startsWith("audio/mp4")) return "mp4";
38
+ if (mimeType.startsWith("audio/ogg")) return "ogg";
39
+ if (mimeType.startsWith("audio/webm")) return "webm";
40
+ const match = mimeType.match(/audio\/(\w+)/);
41
+ return match?.[1] ?? "webm";
42
+ }
43
+
44
+ // src/AudioChunkify.ts
45
+ var AudioChunkify = class {
46
+ constructor() {
47
+ // ── Core recorder ────────────────────────────────────────────────────────
48
+ this._mediaRecorder = null;
49
+ this._stream = null;
50
+ this._audioStreams = [];
51
+ this._mimeType = null;
52
+ this._timeslice = null;
53
+ // ── Event callbacks ───────────────────────────────────────────────────────
54
+ this._onDataAvailableCallback = null;
55
+ this._onStartCallback = null;
56
+ this._onPauseCallback = null;
57
+ this._onResumeCallback = null;
58
+ this._onStopCallback = null;
59
+ this._onErrorCallback = null;
60
+ // ── Time tracking ─────────────────────────────────────────────────────────
61
+ this._recordingTime = 0;
62
+ this._intervalId = null;
63
+ this._onTimeUpdateCallback = null;
64
+ // ── Silence analysis ─────────────────────────────────────────────────────
65
+ this._audioContext = null;
66
+ this._analyserNode = null;
67
+ this._sourceNode = null;
68
+ this._silenceTime = 0;
69
+ this._silenceIntervalId = null;
70
+ this._silenceCheckIntervalId = null;
71
+ this._silenceThreshold = DEFAULT_AUDIO_CONSTANTS.SILENCE_THRESHOLD;
72
+ this._isInSilence = false;
73
+ this._onSilenceDetectedCallback = null;
74
+ this._onAudioDetectedCallback = null;
75
+ // ── Mixed stream ──────────────────────────────────────────────────────────
76
+ this._mixedStreamContext = null;
77
+ this._mixedStreamDestination = null;
78
+ this._mixedStreamSourceNodes = [];
79
+ // ── Chunk management ─────────────────────────────────────────────────────
80
+ this._audioChunks = [];
81
+ this._currentChunkIndex = 0;
82
+ this._processingChunk = false;
83
+ this._isChunkSplit = false;
84
+ this._lastChunkEnd = null;
85
+ this._silenceStartForChunking = null;
86
+ this._chunkSplitIntervalId = null;
87
+ this._onChunkProcessedCallback = null;
88
+ this._pauseStartTime = null;
89
+ this._totalPausedTime = 0;
90
+ // ── Chunk options ─────────────────────────────────────────────────────────
91
+ this._chunkOptions = {
92
+ maxDuration: DEFAULT_AUDIO_CONSTANTS.MAX_CHUNK_DURATION,
93
+ minDuration: DEFAULT_AUDIO_CONSTANTS.MIN_CHUNK_DURATION,
94
+ minSilenceDuration: DEFAULT_AUDIO_CONSTANTS.MIN_SILENCE_DURATION
95
+ };
96
+ }
97
+ // ── Public API ────────────────────────────────────────────────────────────
98
+ /**
99
+ * Initialises the recorder with the given stream and options.
100
+ *
101
+ * @example
102
+ * const recorder = new AudioChunkify()
103
+ * const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
104
+ * recorder.create(stream, { timeslice: 250 })
105
+ */
106
+ create(stream, options = {}) {
107
+ if (!(stream instanceof MediaStream)) {
108
+ throw new TypeError("stream must be a MediaStream instance.");
109
+ }
110
+ if (options.mimeType) {
111
+ if (!MediaRecorder.isTypeSupported(options.mimeType)) {
112
+ throw new Error(`MIME type "${options.mimeType}" is not supported by this browser.`);
113
+ }
114
+ this._mimeType = options.mimeType;
115
+ } else {
116
+ this._mimeType = getCompatibleMimeType();
117
+ }
118
+ this._timeslice = options.timeslice ?? null;
119
+ if (options.silenceThreshold !== void 0) {
120
+ this.setSilenceThreshold(options.silenceThreshold);
121
+ }
122
+ if (options.chunkOptions) {
123
+ this._chunkOptions = { ...this._chunkOptions, ...options.chunkOptions };
124
+ }
125
+ this._audioStreams = [stream];
126
+ this._stream = this._createMixedStream();
127
+ const recorderOptions = {};
128
+ if (this._mimeType) recorderOptions.mimeType = this._mimeType;
129
+ this._mediaRecorder = new MediaRecorder(this._stream, recorderOptions);
130
+ this._setupEventHandlers();
131
+ this._setupAudioAnalysis();
132
+ return this._mediaRecorder;
133
+ }
134
+ /** Starts recording. Optionally overrides the timeslice set in create(). */
135
+ start(timeslice) {
136
+ const mr = this._assertReady();
137
+ if (mr.state === "recording") {
138
+ console.warn("[AudioChunkify] Already recording.");
139
+ return;
140
+ }
141
+ this._silenceStartForChunking = null;
142
+ const slice = timeslice ?? this._timeslice;
143
+ if (slice !== null && slice !== void 0) {
144
+ mr.start(slice);
145
+ } else {
146
+ mr.start();
147
+ }
148
+ }
149
+ /** Pauses an active recording. */
150
+ pause() {
151
+ const mr = this._assertReady();
152
+ if (mr.state === "recording") {
153
+ mr.pause();
154
+ } else {
155
+ console.warn(`[AudioChunkify] Cannot pause \u2014 state is "${mr.state}".`);
156
+ }
157
+ }
158
+ /** Resumes a paused recording. */
159
+ resume() {
160
+ const mr = this._assertReady();
161
+ if (mr.state === "paused") {
162
+ mr.resume();
163
+ } else {
164
+ console.warn(`[AudioChunkify] Cannot resume \u2014 state is "${mr.state}".`);
165
+ }
166
+ }
167
+ /** Stops the recording and finalises all pending chunks. */
168
+ stop() {
169
+ const mr = this._assertReady();
170
+ this._stopChunkSplitting();
171
+ if (mr.state !== "inactive") {
172
+ mr.stop();
173
+ }
174
+ }
175
+ /**
176
+ * Dynamically mixes in an additional audio stream.
177
+ * The recorder is briefly restarted to rebuild the AudioContext graph.
178
+ */
179
+ addAudioStream(stream) {
180
+ if (!(stream instanceof MediaStream)) {
181
+ throw new TypeError("stream must be a MediaStream instance.");
182
+ }
183
+ this._assertReady();
184
+ if (this._audioStreams.includes(stream)) {
185
+ console.warn("[AudioChunkify] Stream is already being mixed.");
186
+ return;
187
+ }
188
+ const { wasRecording, wasPaused } = this._captureState();
189
+ if (wasRecording || wasPaused) this._mediaRecorder?.stop();
190
+ this._audioStreams.push(stream);
191
+ this._recreateMediaRecorder();
192
+ this._restoreState(wasRecording, wasPaused);
193
+ }
194
+ /**
195
+ * Removes a previously added audio stream from the mix.
196
+ * At least one stream must remain — call destroy() to tear everything down.
197
+ */
198
+ removeAudioStream(stream) {
199
+ this._assertReady();
200
+ const index = this._audioStreams.indexOf(stream);
201
+ if (index === -1) {
202
+ console.warn("[AudioChunkify] Stream not found.");
203
+ return;
204
+ }
205
+ if (this._audioStreams.length === 1) {
206
+ throw new Error(
207
+ "Cannot remove the last stream. Call destroy() to clean up instead."
208
+ );
209
+ }
210
+ const { wasRecording, wasPaused } = this._captureState();
211
+ if (wasRecording || wasPaused) this._mediaRecorder?.stop();
212
+ this._audioStreams.splice(index, 1);
213
+ this._recreateMediaRecorder();
214
+ this._restoreState(wasRecording, wasPaused);
215
+ }
216
+ // ── State & metadata ──────────────────────────────────────────────────────
217
+ /** Current recorder state: 'inactive' | 'recording' | 'paused' */
218
+ getState() {
219
+ return this._mediaRecorder?.state ?? "inactive";
220
+ }
221
+ /** Active MIME type (resolved at create() time). */
222
+ getMimeType() {
223
+ return this._mimeType;
224
+ }
225
+ /** The mixed MediaStream used internally by the recorder. */
226
+ getStream() {
227
+ return this._stream;
228
+ }
229
+ /** The underlying MediaRecorder instance. */
230
+ getMediaRecorder() {
231
+ return this._mediaRecorder;
232
+ }
233
+ /** Elapsed recording time in seconds (excludes paused time). */
234
+ getRecordingTime() {
235
+ return this._recordingTime;
236
+ }
237
+ /** Resets the elapsed recording time counter to zero. */
238
+ resetRecordingTime() {
239
+ this._recordingTime = 0;
240
+ this._onTimeUpdateCallback?.(this._recordingTime);
241
+ }
242
+ /** Current continuous silence duration in seconds. */
243
+ getSilenceTime() {
244
+ return this._silenceTime;
245
+ }
246
+ /** Current silence detection threshold in dB. */
247
+ getSilenceThreshold() {
248
+ return this._silenceThreshold;
249
+ }
250
+ /**
251
+ * Updates the silence detection threshold.
252
+ * @param threshold — Must be a negative number in dB (e.g. -50).
253
+ */
254
+ setSilenceThreshold(threshold) {
255
+ if (typeof threshold !== "number" || threshold > 0) {
256
+ throw new RangeError("Silence threshold must be a negative number (dB).");
257
+ }
258
+ this._silenceThreshold = threshold;
259
+ }
260
+ /** Accumulated audio chunks since the last start() or split. */
261
+ getAudioChunks() {
262
+ return [...this._audioChunks];
263
+ }
264
+ /** Zero-based index of the current chunk. */
265
+ getCurrentChunkIndex() {
266
+ return this._currentChunkIndex;
267
+ }
268
+ /** Clears the internal chunk buffer. */
269
+ clearAudioChunks() {
270
+ this._audioChunks = [];
271
+ }
272
+ // ── Event registration ────────────────────────────────────────────────────
273
+ onDataAvailable(callback) {
274
+ this._assertCallback(callback);
275
+ this._onDataAvailableCallback = callback;
276
+ if (this._mediaRecorder) this._mediaRecorder.ondataavailable = callback;
277
+ return this;
278
+ }
279
+ onStart(callback) {
280
+ this._assertCallback(callback);
281
+ this._onStartCallback = callback;
282
+ if (this._mediaRecorder) this._setupEventHandlers();
283
+ return this;
284
+ }
285
+ onPause(callback) {
286
+ this._assertCallback(callback);
287
+ this._onPauseCallback = callback;
288
+ if (this._mediaRecorder) this._setupEventHandlers();
289
+ return this;
290
+ }
291
+ onResume(callback) {
292
+ this._assertCallback(callback);
293
+ this._onResumeCallback = callback;
294
+ if (this._mediaRecorder) this._setupEventHandlers();
295
+ return this;
296
+ }
297
+ onStop(callback) {
298
+ this._assertCallback(callback);
299
+ this._onStopCallback = callback;
300
+ if (this._mediaRecorder) this._setupEventHandlers();
301
+ return this;
302
+ }
303
+ onError(callback) {
304
+ this._assertCallback(callback);
305
+ this._onErrorCallback = callback;
306
+ if (this._mediaRecorder) this._mediaRecorder.onerror = callback;
307
+ return this;
308
+ }
309
+ onTimeUpdate(callback) {
310
+ this._assertCallback(callback);
311
+ this._onTimeUpdateCallback = callback;
312
+ return this;
313
+ }
314
+ onSilenceDetected(callback) {
315
+ this._assertCallback(callback);
316
+ this._onSilenceDetectedCallback = callback;
317
+ return this;
318
+ }
319
+ onAudioDetected(callback) {
320
+ this._assertCallback(callback);
321
+ this._onAudioDetectedCallback = callback;
322
+ return this;
323
+ }
324
+ /**
325
+ * Called each time a chunk is finalised (either by splitting or on stop).
326
+ *
327
+ * @example
328
+ * recorder.onChunkProcessed(async ({ file, index, duration, isLastChunk }) => {
329
+ * await uploadChunk(file)
330
+ * if (isLastChunk) console.log('Recording complete')
331
+ * })
332
+ */
333
+ onChunkProcessed(callback) {
334
+ this._assertCallback(callback);
335
+ this._onChunkProcessedCallback = callback;
336
+ return this;
337
+ }
338
+ /**
339
+ * Fully tears down the recorder: stops recording, closes AudioContexts
340
+ * and clears all internal state.
341
+ *
342
+ * By default also stops every track on the input streams. Pass
343
+ * `{ stopStreams: false }` to keep those streams alive for reuse.
344
+ */
345
+ destroy(options = {}) {
346
+ const { stopStreams = true } = options;
347
+ if (this._mediaRecorder?.state !== "inactive") {
348
+ this._mediaRecorder?.stop();
349
+ }
350
+ if (stopStreams) {
351
+ this._audioStreams.forEach((s) => s.getTracks().forEach((t) => t.stop()));
352
+ }
353
+ this._stopTimeTracking();
354
+ this._recordingTime = 0;
355
+ this._cleanupAudioAnalysis();
356
+ this._silenceTime = 0;
357
+ this._isInSilence = false;
358
+ this._stopChunkSplitting();
359
+ this._audioChunks = [];
360
+ this._currentChunkIndex = 0;
361
+ this._processingChunk = false;
362
+ this._isChunkSplit = false;
363
+ this._lastChunkEnd = null;
364
+ this._silenceStartForChunking = null;
365
+ this._pauseStartTime = null;
366
+ this._totalPausedTime = 0;
367
+ this._cleanupMixedStream();
368
+ this._mediaRecorder = null;
369
+ this._stream = null;
370
+ this._audioStreams = [];
371
+ this._mimeType = null;
372
+ this._timeslice = null;
373
+ this._onDataAvailableCallback = null;
374
+ this._onStartCallback = null;
375
+ this._onPauseCallback = null;
376
+ this._onResumeCallback = null;
377
+ this._onStopCallback = null;
378
+ this._onErrorCallback = null;
379
+ this._onTimeUpdateCallback = null;
380
+ this._onSilenceDetectedCallback = null;
381
+ this._onAudioDetectedCallback = null;
382
+ this._onChunkProcessedCallback = null;
383
+ }
384
+ // ── Private helpers ───────────────────────────────────────────────────────
385
+ _assertReady() {
386
+ if (!this._mediaRecorder) {
387
+ throw new Error("[AudioChunkify] Not initialised. Call create() first.");
388
+ }
389
+ return this._mediaRecorder;
390
+ }
391
+ _assertCallback(fn) {
392
+ if (typeof fn !== "function") {
393
+ throw new TypeError("Callback must be a function.");
394
+ }
395
+ }
396
+ _captureState() {
397
+ return {
398
+ wasRecording: this._mediaRecorder?.state === "recording",
399
+ wasPaused: this._mediaRecorder?.state === "paused"
400
+ };
401
+ }
402
+ _restoreState(wasRecording, wasPaused) {
403
+ if (wasRecording) {
404
+ this.start();
405
+ } else if (wasPaused) {
406
+ this.start();
407
+ this.pause();
408
+ }
409
+ }
410
+ _now() {
411
+ if (this._audioContext?.state === "running") {
412
+ return this._audioContext.currentTime;
413
+ }
414
+ return Date.now() / 1e3;
415
+ }
416
+ // ── Time tracking ─────────────────────────────────────────────────────────
417
+ _startTimeTracking() {
418
+ this._stopTimeTracking();
419
+ this._intervalId = setInterval(() => {
420
+ this._recordingTime++;
421
+ this._onTimeUpdateCallback?.(this._recordingTime);
422
+ }, 1e3);
423
+ }
424
+ _stopTimeTracking() {
425
+ if (this._intervalId !== null) {
426
+ clearInterval(this._intervalId);
427
+ this._intervalId = null;
428
+ }
429
+ }
430
+ _pauseTimeTracking() {
431
+ this._stopTimeTracking();
432
+ }
433
+ _resumeTimeTracking() {
434
+ this._startTimeTracking();
435
+ }
436
+ // ── Audio analysis ────────────────────────────────────────────────────────
437
+ _setupAudioAnalysis() {
438
+ if (!this._stream) return;
439
+ this._cleanupAudioAnalysis();
440
+ try {
441
+ this._audioContext = new AudioContext();
442
+ this._analyserNode = this._audioContext.createAnalyser();
443
+ this._analyserNode.fftSize = DEFAULT_AUDIO_CONSTANTS.ANALYSIS_BUFFER_SIZE;
444
+ this._analyserNode.smoothingTimeConstant = 0.8;
445
+ this._sourceNode = this._audioContext.createMediaStreamSource(this._stream);
446
+ this._sourceNode.connect(this._analyserNode);
447
+ } catch (err) {
448
+ console.warn("[AudioChunkify] Could not set up audio analysis:", err);
449
+ this._cleanupAudioAnalysis();
450
+ }
451
+ }
452
+ _cleanupAudioAnalysis() {
453
+ this._stopSilenceTracking();
454
+ try {
455
+ this._sourceNode?.disconnect();
456
+ } catch {
457
+ }
458
+ this._sourceNode = null;
459
+ try {
460
+ this._analyserNode?.disconnect();
461
+ } catch {
462
+ }
463
+ this._analyserNode = null;
464
+ if (this._audioContext && this._audioContext.state !== "closed") {
465
+ this._audioContext.close().catch(() => {
466
+ });
467
+ }
468
+ this._audioContext = null;
469
+ }
470
+ /**
471
+ * Measures the current audio level in dBFS via time-domain RMS.
472
+ * Shared by silence events and chunk-splitting logic.
473
+ */
474
+ _measureAudioLevelDb() {
475
+ if (!this._analyserNode) return -Infinity;
476
+ const data = new Float32Array(this._analyserNode.fftSize);
477
+ this._analyserNode.getFloatTimeDomainData(data);
478
+ let sum = 0;
479
+ for (let i = 0; i < data.length; i++) sum += data[i] * data[i];
480
+ const rms = Math.sqrt(sum / data.length);
481
+ return rms > 0 ? 20 * Math.log10(rms) : -Infinity;
482
+ }
483
+ _isCurrentlySilent() {
484
+ if (!this._analyserNode || this._audioContext?.state !== "running") return false;
485
+ try {
486
+ return this._measureAudioLevelDb() < this._silenceThreshold;
487
+ } catch {
488
+ return false;
489
+ }
490
+ }
491
+ _checkSilence() {
492
+ if (!this._analyserNode || this._audioContext?.state !== "running") return;
493
+ try {
494
+ const isSilent = this._isCurrentlySilent();
495
+ if (isSilent && !this._isInSilence) {
496
+ this._isInSilence = true;
497
+ if (!this._silenceIntervalId) {
498
+ this._silenceIntervalId = setInterval(() => {
499
+ if (this._isInSilence) this._silenceTime++;
500
+ }, 1e3);
501
+ }
502
+ this._onSilenceDetectedCallback?.();
503
+ } else if (!isSilent && this._isInSilence) {
504
+ this._isInSilence = false;
505
+ if (this._silenceIntervalId !== null) {
506
+ clearInterval(this._silenceIntervalId);
507
+ this._silenceIntervalId = null;
508
+ }
509
+ this._silenceTime = 0;
510
+ this._onAudioDetectedCallback?.();
511
+ }
512
+ } catch (err) {
513
+ console.warn("[AudioChunkify] Silence check error:", err);
514
+ }
515
+ }
516
+ _startSilenceChecking() {
517
+ if (this._silenceCheckIntervalId !== null) {
518
+ clearInterval(this._silenceCheckIntervalId);
519
+ }
520
+ this._silenceCheckIntervalId = setInterval(() => this._checkSilence(), 100);
521
+ }
522
+ _stopSilenceTracking() {
523
+ if (this._silenceIntervalId !== null) {
524
+ clearInterval(this._silenceIntervalId);
525
+ this._silenceIntervalId = null;
526
+ }
527
+ if (this._silenceCheckIntervalId !== null) {
528
+ clearInterval(this._silenceCheckIntervalId);
529
+ this._silenceCheckIntervalId = null;
530
+ }
531
+ this._isInSilence = false;
532
+ }
533
+ // ── Chunk splitting ───────────────────────────────────────────────────────
534
+ _shouldSplitChunk() {
535
+ if (this._mediaRecorder?.state === "paused") return false;
536
+ if (this._lastChunkEnd === null) return false;
537
+ const currentTime = this._now();
538
+ const elapsed = currentTime - this._lastChunkEnd;
539
+ if (elapsed >= this._chunkOptions.maxDuration) return true;
540
+ if (elapsed >= this._chunkOptions.minDuration) {
541
+ if (this._isCurrentlySilent()) {
542
+ if (!this._silenceStartForChunking) {
543
+ this._silenceStartForChunking = currentTime;
544
+ }
545
+ if (currentTime - this._silenceStartForChunking >= this._chunkOptions.minSilenceDuration) {
546
+ return true;
547
+ }
548
+ } else {
549
+ this._silenceStartForChunking = null;
550
+ }
551
+ }
552
+ return false;
553
+ }
554
+ _restartRecorderSegment() {
555
+ if (this._mediaRecorder?.state === "recording" && !this._processingChunk) {
556
+ this._isChunkSplit = true;
557
+ this._mediaRecorder.stop();
558
+ }
559
+ }
560
+ _startChunkSplitting() {
561
+ this._stopChunkSplitting();
562
+ this._chunkSplitIntervalId = setInterval(() => {
563
+ if (this._mediaRecorder?.state === "recording" && !this._processingChunk && this._shouldSplitChunk()) {
564
+ this._restartRecorderSegment();
565
+ }
566
+ }, 100);
567
+ }
568
+ _stopChunkSplitting() {
569
+ if (this._chunkSplitIntervalId !== null) {
570
+ clearInterval(this._chunkSplitIntervalId);
571
+ this._chunkSplitIntervalId = null;
572
+ }
573
+ }
574
+ // ── Mixed stream ──────────────────────────────────────────────────────────
575
+ _createMixedStream() {
576
+ if (!this._audioStreams.length) return new MediaStream();
577
+ if (this._mixedStreamDestination) this._cleanupMixedStream();
578
+ try {
579
+ this._mixedStreamContext = new AudioContext();
580
+ this._mixedStreamDestination = this._mixedStreamContext.createMediaStreamDestination();
581
+ this._mixedStreamSourceNodes = [];
582
+ for (const stream of this._audioStreams) {
583
+ if (stream.getAudioTracks().length > 0) {
584
+ const source = this._mixedStreamContext.createMediaStreamSource(stream);
585
+ const gainNode = this._mixedStreamContext.createGain();
586
+ gainNode.gain.value = 1;
587
+ source.connect(gainNode);
588
+ gainNode.connect(this._mixedStreamDestination);
589
+ this._mixedStreamSourceNodes.push({ source, gainNode });
590
+ }
591
+ }
592
+ return this._mixedStreamDestination.stream;
593
+ } catch (err) {
594
+ console.warn("[AudioChunkify] Could not create mixed stream, falling back:", err);
595
+ const fallback = new MediaStream();
596
+ this._audioStreams.forEach(
597
+ (s) => s.getAudioTracks().forEach((t) => fallback.addTrack(t))
598
+ );
599
+ return fallback;
600
+ }
601
+ }
602
+ _recreateMediaRecorder() {
603
+ this._stream = this._createMixedStream();
604
+ const opts = {};
605
+ if (this._mimeType) opts.mimeType = this._mimeType;
606
+ this._mediaRecorder = new MediaRecorder(this._stream, opts);
607
+ this._setupEventHandlers();
608
+ this._setupAudioAnalysis();
609
+ }
610
+ _cleanupMixedStream() {
611
+ for (const { source, gainNode } of this._mixedStreamSourceNodes) {
612
+ try {
613
+ gainNode.disconnect();
614
+ } catch {
615
+ }
616
+ try {
617
+ source.disconnect();
618
+ } catch {
619
+ }
620
+ }
621
+ this._mixedStreamSourceNodes = [];
622
+ try {
623
+ this._mixedStreamDestination?.stream.getTracks().forEach((t) => t.stop());
624
+ } catch {
625
+ }
626
+ this._mixedStreamDestination = null;
627
+ if (this._mixedStreamContext?.state !== "closed") {
628
+ this._mixedStreamContext?.close().catch(() => {
629
+ });
630
+ }
631
+ this._mixedStreamContext = null;
632
+ }
633
+ // ── Event wiring ──────────────────────────────────────────────────────────
634
+ _setupEventHandlers() {
635
+ if (!this._mediaRecorder) return;
636
+ this._mediaRecorder.ondataavailable = (event) => {
637
+ if (event.data?.size > 0) {
638
+ this._audioChunks.push(event.data);
639
+ this._onDataAvailableCallback?.(event);
640
+ }
641
+ };
642
+ this._mediaRecorder.onstart = () => {
643
+ this._startTimeTracking();
644
+ if (this._audioContext?.state === "suspended") {
645
+ this._audioContext.resume();
646
+ }
647
+ this._startSilenceChecking();
648
+ if (this._lastChunkEnd === null) {
649
+ this._lastChunkEnd = this._now();
650
+ }
651
+ this._audioChunks = [];
652
+ this._silenceStartForChunking = null;
653
+ this._startChunkSplitting();
654
+ this._onStartCallback?.();
655
+ };
656
+ this._mediaRecorder.onpause = () => {
657
+ this._pauseTimeTracking();
658
+ this._stopSilenceTracking();
659
+ this._stopChunkSplitting();
660
+ this._pauseStartTime = this._now();
661
+ this._onPauseCallback?.();
662
+ };
663
+ this._mediaRecorder.onresume = () => {
664
+ this._resumeTimeTracking();
665
+ if (this._audioContext?.state === "suspended") {
666
+ this._audioContext.resume();
667
+ }
668
+ if (this._pauseStartTime !== null) {
669
+ const pausedDuration = this._now() - this._pauseStartTime;
670
+ this._totalPausedTime += pausedDuration;
671
+ if (this._lastChunkEnd !== null) {
672
+ this._lastChunkEnd += pausedDuration;
673
+ }
674
+ this._pauseStartTime = null;
675
+ }
676
+ this._startSilenceChecking();
677
+ this._startChunkSplitting();
678
+ this._onResumeCallback?.();
679
+ };
680
+ this._mediaRecorder.onstop = async () => {
681
+ this._stopTimeTracking();
682
+ this._stopSilenceTracking();
683
+ this._stopChunkSplitting();
684
+ if (this._isChunkSplit) {
685
+ await this._processChunk(false);
686
+ this._audioChunks = [];
687
+ this._silenceStartForChunking = null;
688
+ const opts = {};
689
+ if (this._mimeType) opts.mimeType = this._mimeType;
690
+ this._mediaRecorder = new MediaRecorder(this._stream, opts);
691
+ this._setupEventHandlers();
692
+ this._isChunkSplit = false;
693
+ if (this._timeslice !== null) {
694
+ this._mediaRecorder.start(this._timeslice);
695
+ } else {
696
+ this._mediaRecorder.start();
697
+ }
698
+ } else {
699
+ await this._processChunk(true);
700
+ this.resetRecordingTime();
701
+ this._silenceTime = 0;
702
+ this._audioChunks = [];
703
+ this._currentChunkIndex = 0;
704
+ this._lastChunkEnd = null;
705
+ this._silenceStartForChunking = null;
706
+ this._pauseStartTime = null;
707
+ this._totalPausedTime = 0;
708
+ this._onStopCallback?.();
709
+ }
710
+ };
711
+ if (this._onErrorCallback) {
712
+ this._mediaRecorder.onerror = this._onErrorCallback;
713
+ }
714
+ }
715
+ async _processChunk(isLastChunk) {
716
+ if (!this._audioChunks.length || !this._onChunkProcessedCallback || this._processingChunk) return;
717
+ this._processingChunk = true;
718
+ try {
719
+ const mimeType = this._mimeType ?? getCompatibleMimeType() ?? "audio/webm";
720
+ const ext = getFileExtensionFromMimeType(mimeType);
721
+ const now = this._now();
722
+ let pausedCompensation = 0;
723
+ if (this._pauseStartTime !== null) {
724
+ pausedCompensation = now - this._pauseStartTime;
725
+ }
726
+ const duration = this._lastChunkEnd !== null ? now - this._lastChunkEnd - pausedCompensation : 0;
727
+ const index = this._currentChunkIndex;
728
+ const blob = new Blob(this._audioChunks, { type: mimeType });
729
+ const file = new File([blob], `chunk-${index}.${ext}`, { type: mimeType });
730
+ await this._onChunkProcessedCallback({ file, index, duration, isLastChunk });
731
+ if (!isLastChunk) {
732
+ this._currentChunkIndex = index + 1;
733
+ this._lastChunkEnd = this._now();
734
+ this._totalPausedTime = 0;
735
+ }
736
+ } catch (err) {
737
+ console.error("[AudioChunkify] Error processing chunk:", err);
738
+ } finally {
739
+ this._processingChunk = false;
740
+ }
741
+ }
742
+ };
743
+
744
+ export { AudioChunkify, DEFAULT_AUDIO_CONSTANTS, getCompatibleMimeType, getFileExtensionFromMimeType };
745
+ //# sourceMappingURL=index.js.map
746
+ //# sourceMappingURL=index.js.map