mediabunny 1.0.2 → 1.0.4

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.
Files changed (188) hide show
  1. package/README.md +4 -4
  2. package/dist/mediabunny.d.ts +4 -2
  3. package/dist/modules/codec-data.d.ts +95 -0
  4. package/dist/modules/codec-data.d.ts.map +1 -0
  5. package/dist/modules/codec-data.js +1103 -0
  6. package/dist/modules/codec.d.ts +250 -0
  7. package/dist/modules/codec.d.ts.map +1 -0
  8. package/dist/modules/codec.js +1100 -0
  9. package/dist/modules/conversion.d.ts +158 -0
  10. package/dist/modules/conversion.d.ts.map +1 -0
  11. package/dist/modules/conversion.js +866 -0
  12. package/dist/modules/custom-coder.d.ts +119 -0
  13. package/dist/modules/custom-coder.d.ts.map +1 -0
  14. package/dist/modules/custom-coder.js +91 -0
  15. package/dist/modules/demuxer.d.ts +17 -0
  16. package/dist/modules/demuxer.d.ts.map +1 -0
  17. package/dist/modules/demuxer.js +12 -0
  18. package/dist/modules/index.d.ts +25 -0
  19. package/dist/modules/index.d.ts.map +1 -0
  20. package/dist/modules/index.js +24 -0
  21. package/dist/modules/input-format.d.ts +121 -0
  22. package/dist/modules/input-format.d.ts.map +1 -0
  23. package/dist/modules/input-format.js +333 -0
  24. package/dist/modules/input-track.d.ts +138 -0
  25. package/dist/modules/input-track.d.ts.map +1 -0
  26. package/dist/modules/input-track.js +230 -0
  27. package/dist/modules/input.d.ts +55 -0
  28. package/dist/modules/input.d.ts.map +1 -0
  29. package/dist/modules/input.js +104 -0
  30. package/dist/modules/isobmff/isobmff-boxes.d.ts +202 -0
  31. package/dist/modules/isobmff/isobmff-boxes.d.ts.map +1 -0
  32. package/dist/modules/isobmff/isobmff-boxes.js +1111 -0
  33. package/dist/modules/isobmff/isobmff-demuxer.d.ts +163 -0
  34. package/dist/modules/isobmff/isobmff-demuxer.d.ts.map +1 -0
  35. package/dist/modules/isobmff/isobmff-demuxer.js +2080 -0
  36. package/dist/modules/isobmff/isobmff-misc.d.ts +14 -0
  37. package/dist/modules/isobmff/isobmff-misc.d.ts.map +1 -0
  38. package/dist/modules/isobmff/isobmff-misc.js +20 -0
  39. package/dist/modules/isobmff/isobmff-muxer.d.ts +142 -0
  40. package/dist/modules/isobmff/isobmff-muxer.d.ts.map +1 -0
  41. package/dist/modules/isobmff/isobmff-muxer.js +858 -0
  42. package/dist/modules/isobmff/isobmff-reader.d.ts +36 -0
  43. package/dist/modules/isobmff/isobmff-reader.d.ts.map +1 -0
  44. package/dist/modules/isobmff/isobmff-reader.js +105 -0
  45. package/dist/modules/matroska/ebml.d.ts +156 -0
  46. package/dist/modules/matroska/ebml.d.ts.map +1 -0
  47. package/dist/modules/matroska/ebml.js +529 -0
  48. package/dist/modules/matroska/matroska-demuxer.d.ts +119 -0
  49. package/dist/modules/matroska/matroska-demuxer.d.ts.map +1 -0
  50. package/dist/modules/matroska/matroska-demuxer.js +1330 -0
  51. package/dist/modules/matroska/matroska-misc.d.ts +14 -0
  52. package/dist/modules/matroska/matroska-misc.d.ts.map +1 -0
  53. package/dist/modules/matroska/matroska-misc.js +20 -0
  54. package/dist/modules/matroska/matroska-muxer.d.ts +71 -0
  55. package/dist/modules/matroska/matroska-muxer.d.ts.map +1 -0
  56. package/dist/modules/matroska/matroska-muxer.js +729 -0
  57. package/dist/modules/media-sink.d.ts +272 -0
  58. package/dist/modules/media-sink.d.ts.map +1 -0
  59. package/dist/modules/media-sink.js +1264 -0
  60. package/dist/modules/media-source.d.ts +230 -0
  61. package/dist/modules/media-source.d.ts.map +1 -0
  62. package/dist/modules/media-source.js +1095 -0
  63. package/dist/modules/misc.d.ts +127 -0
  64. package/dist/modules/misc.d.ts.map +1 -0
  65. package/dist/modules/misc.js +462 -0
  66. package/dist/modules/mp3/mp3-demuxer.d.ts +32 -0
  67. package/dist/modules/mp3/mp3-demuxer.d.ts.map +1 -0
  68. package/dist/modules/mp3/mp3-demuxer.js +160 -0
  69. package/dist/modules/mp3/mp3-misc.d.ts +37 -0
  70. package/dist/modules/mp3/mp3-misc.d.ts.map +1 -0
  71. package/dist/modules/mp3/mp3-misc.js +122 -0
  72. package/dist/modules/mp3/mp3-muxer.d.ts +27 -0
  73. package/dist/modules/mp3/mp3-muxer.d.ts.map +1 -0
  74. package/dist/modules/mp3/mp3-muxer.js +112 -0
  75. package/dist/modules/mp3/mp3-reader.d.ts +25 -0
  76. package/dist/modules/mp3/mp3-reader.d.ts.map +1 -0
  77. package/dist/modules/mp3/mp3-reader.js +73 -0
  78. package/dist/modules/mp3/mp3-writer.d.ts +30 -0
  79. package/dist/modules/mp3/mp3-writer.d.ts.map +1 -0
  80. package/dist/modules/mp3/mp3-writer.js +72 -0
  81. package/dist/modules/muxer.d.ts +32 -0
  82. package/dist/modules/muxer.d.ts.map +1 -0
  83. package/dist/modules/muxer.js +50 -0
  84. package/dist/modules/ogg/ogg-demuxer.d.ts +53 -0
  85. package/dist/modules/ogg/ogg-demuxer.d.ts.map +1 -0
  86. package/dist/modules/ogg/ogg-demuxer.js +701 -0
  87. package/dist/modules/ogg/ogg-misc.d.ts +27 -0
  88. package/dist/modules/ogg/ogg-misc.d.ts.map +1 -0
  89. package/dist/modules/ogg/ogg-misc.js +78 -0
  90. package/dist/modules/ogg/ogg-muxer.d.ts +58 -0
  91. package/dist/modules/ogg/ogg-muxer.d.ts.map +1 -0
  92. package/dist/modules/ogg/ogg-muxer.js +345 -0
  93. package/dist/modules/ogg/ogg-reader.d.ts +37 -0
  94. package/dist/modules/ogg/ogg-reader.d.ts.map +1 -0
  95. package/dist/modules/ogg/ogg-reader.js +104 -0
  96. package/dist/modules/output-format.d.ts +285 -0
  97. package/dist/modules/output-format.d.ts.map +1 -0
  98. package/dist/modules/output-format.js +385 -0
  99. package/dist/modules/output.d.ts +134 -0
  100. package/dist/modules/output.d.ts.map +1 -0
  101. package/dist/modules/output.js +269 -0
  102. package/dist/modules/packet.d.ts +86 -0
  103. package/dist/modules/packet.d.ts.map +1 -0
  104. package/dist/modules/packet.js +133 -0
  105. package/dist/modules/pcm.d.ts +12 -0
  106. package/dist/modules/pcm.d.ts.map +1 -0
  107. package/dist/modules/pcm.js +85 -0
  108. package/dist/modules/reader.d.ts +40 -0
  109. package/dist/modules/reader.d.ts.map +1 -0
  110. package/dist/modules/reader.js +153 -0
  111. package/dist/modules/sample.d.ts +186 -0
  112. package/dist/modules/sample.d.ts.map +1 -0
  113. package/dist/modules/sample.js +798 -0
  114. package/dist/modules/source.d.ts +76 -0
  115. package/dist/modules/source.d.ts.map +1 -0
  116. package/dist/modules/source.js +183 -0
  117. package/dist/modules/subtitles.d.ts +37 -0
  118. package/dist/modules/subtitles.d.ts.map +1 -0
  119. package/dist/modules/subtitles.js +89 -0
  120. package/dist/modules/target.d.ts +58 -0
  121. package/dist/modules/target.d.ts.map +1 -0
  122. package/dist/modules/target.js +63 -0
  123. package/dist/modules/wave/riff-reader.d.ts +19 -0
  124. package/dist/modules/wave/riff-reader.d.ts.map +1 -0
  125. package/dist/modules/wave/riff-reader.js +38 -0
  126. package/dist/modules/wave/riff-writer.d.ts +18 -0
  127. package/dist/modules/wave/riff-writer.d.ts.map +1 -0
  128. package/dist/modules/wave/riff-writer.js +25 -0
  129. package/dist/modules/wave/wave-demuxer.d.ts +42 -0
  130. package/dist/modules/wave/wave-demuxer.d.ts.map +1 -0
  131. package/dist/modules/wave/wave-demuxer.js +239 -0
  132. package/dist/modules/wave/wave-muxer.d.ts +27 -0
  133. package/dist/modules/wave/wave-muxer.d.ts.map +1 -0
  134. package/dist/modules/wave/wave-muxer.js +108 -0
  135. package/dist/modules/writer.d.ts +85 -0
  136. package/dist/modules/writer.d.ts.map +1 -0
  137. package/dist/modules/writer.js +347 -0
  138. package/package.json +14 -10
  139. package/src/codec-data.ts +1394 -0
  140. package/src/codec.ts +1392 -0
  141. package/src/conversion.ts +1199 -0
  142. package/src/custom-coder.ts +162 -0
  143. package/src/demuxer.ts +22 -0
  144. package/src/index.ts +148 -0
  145. package/src/input-format.ts +386 -0
  146. package/src/input-track.ts +341 -0
  147. package/src/input.ts +138 -0
  148. package/src/isobmff/isobmff-boxes.ts +1365 -0
  149. package/src/isobmff/isobmff-demuxer.ts +2646 -0
  150. package/src/isobmff/isobmff-misc.ts +29 -0
  151. package/src/isobmff/isobmff-muxer.ts +1210 -0
  152. package/src/isobmff/isobmff-reader.ts +137 -0
  153. package/src/matroska/ebml.ts +595 -0
  154. package/src/matroska/matroska-demuxer.ts +1677 -0
  155. package/src/matroska/matroska-misc.ts +29 -0
  156. package/src/matroska/matroska-muxer.ts +999 -0
  157. package/src/media-sink.ts +1577 -0
  158. package/src/media-source.ts +1406 -0
  159. package/src/misc.ts +584 -0
  160. package/src/mp3/mp3-demuxer.ts +228 -0
  161. package/src/mp3/mp3-misc.ts +154 -0
  162. package/src/mp3/mp3-muxer.ts +153 -0
  163. package/src/mp3/mp3-reader.ts +93 -0
  164. package/src/mp3/mp3-writer.ts +112 -0
  165. package/src/muxer.ts +88 -0
  166. package/src/ogg/ogg-demuxer.ts +985 -0
  167. package/src/ogg/ogg-misc.ts +116 -0
  168. package/src/ogg/ogg-muxer.ts +480 -0
  169. package/src/ogg/ogg-reader.ts +143 -0
  170. package/src/output-format.ts +689 -0
  171. package/src/output.ts +427 -0
  172. package/src/packet.ts +180 -0
  173. package/src/pcm.ts +112 -0
  174. package/src/reader.ts +205 -0
  175. package/src/sample.ts +1029 -0
  176. package/src/source.ts +279 -0
  177. package/src/subtitles.ts +131 -0
  178. package/src/target.ts +106 -0
  179. package/src/tsconfig.json +13 -0
  180. package/src/wave/riff-reader.ts +48 -0
  181. package/src/wave/riff-writer.ts +30 -0
  182. package/src/wave/wave-demuxer.ts +317 -0
  183. package/src/wave/wave-muxer.ts +145 -0
  184. package/src/writer.ts +461 -0
  185. /package/dist/{mediabunny.js → bundles/mediabunny.cjs} +0 -0
  186. /package/dist/{mediabunny.min.js → bundles/mediabunny.min.cjs} +0 -0
  187. /package/dist/{mediabunny.min.mjs → bundles/mediabunny.min.mjs} +0 -0
  188. /package/dist/{mediabunny.mjs → bundles/mediabunny.mjs} +0 -0
@@ -0,0 +1,866 @@
1
+ /*!
2
+ * Copyright (c) 2025-present, Vanilagy and contributors
3
+ *
4
+ * This Source Code Form is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+ import { AUDIO_CODECS, getFirstEncodableVideoCodec, getEncodableAudioCodecs, NON_PCM_AUDIO_CODECS, Quality, QUALITY_HIGH, VIDEO_CODECS, } from './codec.js';
9
+ import { Input } from './input.js';
10
+ import { AudioSampleSink, CanvasSink, EncodedPacketSink, VideoSampleSink, } from './media-sink.js';
11
+ import { EncodedVideoPacketSource, EncodedAudioPacketSource, VideoSampleSource, AudioSampleSource, } from './media-source.js';
12
+ import { assert, clamp, normalizeRotation, promiseWithResolvers } from './misc.js';
13
+ import { Output } from './output.js';
14
+ import { AudioSample, VideoSample } from './sample.js';
15
+ const FALLBACK_NUMBER_OF_CHANNELS = 2;
16
+ const FALLBACK_SAMPLE_RATE = 48000;
17
+ /**
18
+ * Represents a media file conversion process, used to convert one media file into another. In addition to conversion,
19
+ * this class can be used to resize and rotate video, resample audio, drop tracks, or trim to a specific time range.
20
+ * @public
21
+ */
22
+ export class Conversion {
23
+ /** Initializes a new conversion process without starting the conversion. */
24
+ static async init(options) {
25
+ const conversion = new Conversion(options);
26
+ await conversion._init();
27
+ return conversion;
28
+ }
29
+ constructor(options) {
30
+ /** @internal */
31
+ this._addedCounts = {
32
+ video: 0,
33
+ audio: 0,
34
+ subtitle: 0,
35
+ };
36
+ /** @internal */
37
+ this._totalTrackCount = 0;
38
+ /** @internal */
39
+ this._trackPromises = [];
40
+ /** @internal */
41
+ this._executed = false;
42
+ /** @internal */
43
+ this._synchronizer = new TrackSynchronizer();
44
+ /** @internal */
45
+ this._totalDuration = null;
46
+ /** @internal */
47
+ this._maxTimestamps = new Map(); // Track ID -> timestamp
48
+ /** @internal */
49
+ this._canceled = false;
50
+ /**
51
+ * A callback that is fired whenever the conversion progresses. Returns a number between 0 and 1, indicating the
52
+ * completion of the conversion. Note that a progress of 1 doesn't necessarily mean the conversion is complete;
53
+ * the conversion is complete once `execute` resolves.
54
+ *
55
+ * In order for progress to be computed, this property must be set before `execute` is called.
56
+ */
57
+ this.onProgress = undefined;
58
+ /** @internal */
59
+ this._computeProgress = false;
60
+ /** @internal */
61
+ this._lastProgress = 0;
62
+ /** The list of tracks that are included in the output file. */
63
+ this.utilizedTracks = [];
64
+ /** The list of tracks from the input file that have been discarded, alongside the discard reason. */
65
+ this.discardedTracks = [];
66
+ if (!options || typeof options !== 'object') {
67
+ throw new TypeError('options must be an object.');
68
+ }
69
+ if (!(options.input instanceof Input)) {
70
+ throw new TypeError('options.input must be an Input.');
71
+ }
72
+ if (!(options.output instanceof Output)) {
73
+ throw new TypeError('options.output must be an Output.');
74
+ }
75
+ if (options.output._tracks.length > 0 || options.output.state !== 'pending') {
76
+ throw new TypeError('options.output must be fresh: no tracks added and not started.');
77
+ }
78
+ if (options.video !== undefined && (!options.video || typeof options.video !== 'object')) {
79
+ throw new TypeError('options.video, when provided, must be an object.');
80
+ }
81
+ if (options.video?.discard !== undefined && typeof options.video.discard !== 'boolean') {
82
+ throw new TypeError('options.video.discard, when provided, must be a boolean.');
83
+ }
84
+ if (options.video?.forceTranscode !== undefined && typeof options.video.forceTranscode !== 'boolean') {
85
+ throw new TypeError('options.video.forceTranscode, when provided, must be a boolean.');
86
+ }
87
+ if (options.video?.codec !== undefined && !VIDEO_CODECS.includes(options.video.codec)) {
88
+ throw new TypeError(`options.video.codec, when provided, must be one of: ${VIDEO_CODECS.join(', ')}.`);
89
+ }
90
+ if (options.video?.bitrate !== undefined
91
+ && !(options.video.bitrate instanceof Quality)
92
+ && (!Number.isInteger(options.video.bitrate) || options.video.bitrate <= 0)) {
93
+ throw new TypeError('options.video.bitrate, when provided, must be a positive integer or a quality.');
94
+ }
95
+ if (options.video?.width !== undefined
96
+ && (!Number.isInteger(options.video.width) || options.video.width <= 0)) {
97
+ throw new TypeError('options.video.width, when provided, must be a positive integer.');
98
+ }
99
+ if (options.video?.height !== undefined
100
+ && (!Number.isInteger(options.video.height) || options.video.height <= 0)) {
101
+ throw new TypeError('options.video.height, when provided, must be a positive integer.');
102
+ }
103
+ if (options.video?.fit !== undefined && !['fill', 'contain', 'cover'].includes(options.video.fit)) {
104
+ throw new TypeError('options.video.fit, when provided, must be one of "fill", "contain", or "cover".');
105
+ }
106
+ if (options.video?.width !== undefined
107
+ && options.video.height !== undefined
108
+ && options.video.fit === undefined) {
109
+ throw new TypeError('When both options.video.width and options.video.height are provided, options.video.fit must also be'
110
+ + ' provided.');
111
+ }
112
+ if (options.video?.rotate !== undefined && ![0, 90, 180, 270].includes(options.video.rotate)) {
113
+ throw new TypeError('options.video.rotate, when provided, must be 0, 90, 180 or 270.');
114
+ }
115
+ if (options.audio !== undefined && (!options.audio || typeof options.audio !== 'object')) {
116
+ throw new TypeError('options.video, when provided, must be an object.');
117
+ }
118
+ if (options.audio?.discard !== undefined && typeof options.audio.discard !== 'boolean') {
119
+ throw new TypeError('options.audio.discard, when provided, must be a boolean.');
120
+ }
121
+ if (options.audio?.forceTranscode !== undefined && typeof options.audio.forceTranscode !== 'boolean') {
122
+ throw new TypeError('options.audio.forceTranscode, when provided, must be a boolean.');
123
+ }
124
+ if (options.audio?.codec !== undefined && !AUDIO_CODECS.includes(options.audio.codec)) {
125
+ throw new TypeError(`options.audio.codec, when provided, must be one of: ${AUDIO_CODECS.join(', ')}.`);
126
+ }
127
+ if (options.audio?.bitrate !== undefined
128
+ && !(options.audio.bitrate instanceof Quality)
129
+ && (!Number.isInteger(options.audio.bitrate) || options.audio.bitrate <= 0)) {
130
+ throw new TypeError('options.audio.bitrate, when provided, must be a positive integer or a quality.');
131
+ }
132
+ if (options.audio?.numberOfChannels !== undefined
133
+ && (!Number.isInteger(options.audio.numberOfChannels) || options.audio.numberOfChannels <= 0)) {
134
+ throw new TypeError('options.audio.numberOfChannels, when provided, must be a positive integer.');
135
+ }
136
+ if (options.audio?.sampleRate !== undefined
137
+ && (!Number.isInteger(options.audio.sampleRate) || options.audio.sampleRate <= 0)) {
138
+ throw new TypeError('options.audio.sampleRate, when provided, must be a positive integer.');
139
+ }
140
+ if (options.trim !== undefined && (!options.trim || typeof options.trim !== 'object')) {
141
+ throw new TypeError('options.trim, when provided, must be an object.');
142
+ }
143
+ if (options.trim?.start !== undefined && (!Number.isFinite(options.trim.start) || options.trim.start < 0)) {
144
+ throw new TypeError('options.trim.start, when provided, must be a non-negative number.');
145
+ }
146
+ if (options.trim?.end !== undefined && (!Number.isFinite(options.trim.end) || options.trim.end < 0)) {
147
+ throw new TypeError('options.trim.end, when provided, must be a non-negative number.');
148
+ }
149
+ if (options.trim?.start !== undefined
150
+ && options.trim.end !== undefined
151
+ && options.trim.start >= options.trim.end) {
152
+ throw new TypeError('options.trim.start must be less than options.trim.end.');
153
+ }
154
+ this._options = options;
155
+ this.input = options.input;
156
+ this.output = options.output;
157
+ this._startTimestamp = options.trim?.start ?? 0;
158
+ this._endTimestamp = options.trim?.end ?? Infinity;
159
+ const { promise: started, resolve: start } = promiseWithResolvers();
160
+ this._started = started;
161
+ this._start = start;
162
+ }
163
+ /** @internal */
164
+ async _init() {
165
+ const inputTracks = await this.input.getTracks();
166
+ const outputTrackCounts = this.output.format.getSupportedTrackCounts();
167
+ for (const track of inputTracks) {
168
+ if (track.isVideoTrack() && this._options.video?.discard) {
169
+ this.discardedTracks.push({
170
+ track,
171
+ reason: 'discarded_by_user',
172
+ });
173
+ continue;
174
+ }
175
+ if (track.isAudioTrack() && this._options.audio?.discard) {
176
+ this.discardedTracks.push({
177
+ track,
178
+ reason: 'discarded_by_user',
179
+ });
180
+ continue;
181
+ }
182
+ if (this._totalTrackCount === outputTrackCounts.total.max) {
183
+ this.discardedTracks.push({
184
+ track,
185
+ reason: 'max_track_count_reached',
186
+ });
187
+ continue;
188
+ }
189
+ if (this._addedCounts[track.type] === outputTrackCounts[track.type].max) {
190
+ this.discardedTracks.push({
191
+ track,
192
+ reason: 'max_track_count_of_type_reached',
193
+ });
194
+ continue;
195
+ }
196
+ if (track.isVideoTrack()) {
197
+ await this._processVideoTrack(track);
198
+ }
199
+ else if (track.isAudioTrack()) {
200
+ await this._processAudioTrack(track);
201
+ }
202
+ }
203
+ const unintentionallyDiscardedTracks = this.discardedTracks.filter(x => x.reason !== 'discarded_by_user');
204
+ if (unintentionallyDiscardedTracks.length > 0) {
205
+ // Let's give the user a notice/warning about discarded tracks so they aren't confused
206
+ console.warn('Some tracks had to be discarded from the conversion:', unintentionallyDiscardedTracks);
207
+ }
208
+ }
209
+ /** Executes the conversion process. Resolves once conversion is complete. */
210
+ async execute() {
211
+ if (this._executed) {
212
+ throw new Error('Conversion cannot be executed twice.');
213
+ }
214
+ this._executed = true;
215
+ if (this.onProgress) {
216
+ this._computeProgress = true;
217
+ this._totalDuration = Math.min(await this.input.computeDuration() - this._startTimestamp, this._endTimestamp - this._startTimestamp);
218
+ this.onProgress?.(0);
219
+ }
220
+ await this.output.start();
221
+ this._start();
222
+ try {
223
+ await Promise.all(this._trackPromises);
224
+ }
225
+ catch (error) {
226
+ if (!this._canceled) {
227
+ // Make sure to cancel to stop other encoding processes and clean up resources
228
+ await this.cancel();
229
+ }
230
+ throw error;
231
+ }
232
+ if (this._canceled) {
233
+ await new Promise(() => { }); // Never resolve
234
+ }
235
+ await this.output.finalize();
236
+ if (this._computeProgress) {
237
+ this.onProgress?.(1);
238
+ }
239
+ }
240
+ /** Cancels the conversion process. Does nothing if the conversion is already complete. */
241
+ async cancel() {
242
+ if (this.output.state === 'finalizing' || this.output.state === 'finalized') {
243
+ return;
244
+ }
245
+ if (this._canceled) {
246
+ console.warn('Conversion already canceled.');
247
+ return;
248
+ }
249
+ this._canceled = true;
250
+ await this.output.cancel();
251
+ }
252
+ /** @internal */
253
+ async _processVideoTrack(track) {
254
+ const sourceCodec = track.codec;
255
+ if (!sourceCodec) {
256
+ this.discardedTracks.push({
257
+ track,
258
+ reason: 'unknown_source_codec',
259
+ });
260
+ return;
261
+ }
262
+ let videoSource;
263
+ const totalRotation = normalizeRotation(track.rotation + (this._options.video?.rotate ?? 0));
264
+ const outputSupportsRotation = this.output.format.supportsVideoRotationMetadata;
265
+ const [originalWidth, originalHeight] = totalRotation % 180 === 0
266
+ ? [track.codedWidth, track.codedHeight]
267
+ : [track.codedHeight, track.codedWidth];
268
+ let width = originalWidth;
269
+ let height = originalHeight;
270
+ const aspectRatio = width / height;
271
+ // A lot of video encoders require that the dimensions be multiples of 2
272
+ const ceilToMultipleOfTwo = (value) => Math.ceil(value / 2) * 2;
273
+ if (this._options.video?.width !== undefined && this._options.video.height === undefined) {
274
+ width = ceilToMultipleOfTwo(this._options.video.width);
275
+ height = ceilToMultipleOfTwo(Math.round(width / aspectRatio));
276
+ }
277
+ else if (this._options.video?.width === undefined && this._options.video?.height !== undefined) {
278
+ height = ceilToMultipleOfTwo(this._options.video.height);
279
+ width = ceilToMultipleOfTwo(Math.round(height * aspectRatio));
280
+ }
281
+ else if (this._options.video?.width !== undefined && this._options.video.height !== undefined) {
282
+ width = ceilToMultipleOfTwo(this._options.video.width);
283
+ height = ceilToMultipleOfTwo(this._options.video.height);
284
+ }
285
+ const firstTimestamp = await track.getFirstTimestamp();
286
+ const needsTranscode = !!this._options.video?.forceTranscode || this._startTimestamp > 0 || firstTimestamp < 0;
287
+ const needsRerender = width !== originalWidth
288
+ || height !== originalHeight
289
+ || (totalRotation !== 0 && !outputSupportsRotation);
290
+ let videoCodecs = this.output.format.getSupportedVideoCodecs();
291
+ if (!needsTranscode
292
+ && !this._options.video?.bitrate
293
+ && !needsRerender
294
+ && videoCodecs.includes(sourceCodec)
295
+ && (!this._options.video?.codec || this._options.video?.codec === sourceCodec)) {
296
+ // Fast path, we can simply copy over the encoded packets
297
+ const source = new EncodedVideoPacketSource(sourceCodec);
298
+ videoSource = source;
299
+ this._trackPromises.push((async () => {
300
+ await this._started;
301
+ const sink = new EncodedPacketSink(track);
302
+ const decoderConfig = await track.getDecoderConfig();
303
+ const meta = { decoderConfig: decoderConfig ?? undefined };
304
+ const endPacket = Number.isFinite(this._endTimestamp)
305
+ ? await sink.getPacket(this._endTimestamp, { metadataOnly: true }) ?? undefined
306
+ : undefined;
307
+ for await (const packet of sink.packets(undefined, endPacket)) {
308
+ if (this._synchronizer.shouldWait(track.id, packet.timestamp)) {
309
+ await this._synchronizer.wait(packet.timestamp);
310
+ }
311
+ if (this._canceled) {
312
+ return;
313
+ }
314
+ await source.add(packet, meta);
315
+ this._reportProgress(track.id, packet.timestamp + packet.duration);
316
+ }
317
+ source.close();
318
+ this._synchronizer.closeTrack(track.id);
319
+ })());
320
+ }
321
+ else {
322
+ // We need to decode & reencode the video
323
+ const canDecode = await track.canDecode();
324
+ if (!canDecode) {
325
+ this.discardedTracks.push({
326
+ track,
327
+ reason: 'undecodable_source_codec',
328
+ });
329
+ return;
330
+ }
331
+ if (this._options.video?.codec) {
332
+ videoCodecs = videoCodecs.filter(codec => codec === this._options.video?.codec);
333
+ }
334
+ const bitrate = this._options.video?.bitrate ?? QUALITY_HIGH;
335
+ const encodableCodec = await getFirstEncodableVideoCodec(videoCodecs, { width, height, bitrate });
336
+ if (!encodableCodec) {
337
+ this.discardedTracks.push({
338
+ track,
339
+ reason: 'no_encodable_target_codec',
340
+ });
341
+ return;
342
+ }
343
+ const encodingConfig = {
344
+ codec: encodableCodec,
345
+ bitrate,
346
+ onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration),
347
+ };
348
+ if (needsRerender) {
349
+ const source = new VideoSampleSource(encodingConfig);
350
+ videoSource = source;
351
+ this._trackPromises.push((async () => {
352
+ await this._started;
353
+ const sink = new CanvasSink(track, {
354
+ width,
355
+ height,
356
+ fit: this._options.video?.fit ?? 'fill',
357
+ rotation: totalRotation, // Bake the rotation into the output
358
+ poolSize: 1,
359
+ });
360
+ const iterator = sink.canvases(this._startTimestamp, this._endTimestamp);
361
+ for await (const { canvas, timestamp, duration } of iterator) {
362
+ if (this._synchronizer.shouldWait(track.id, timestamp)) {
363
+ await this._synchronizer.wait(timestamp);
364
+ }
365
+ if (this._canceled) {
366
+ return;
367
+ }
368
+ const sample = new VideoSample(canvas, {
369
+ timestamp: Math.max(timestamp - this._startTimestamp, 0),
370
+ duration,
371
+ });
372
+ await source.add(sample);
373
+ sample.close();
374
+ }
375
+ })());
376
+ }
377
+ else {
378
+ const source = new VideoSampleSource(encodingConfig);
379
+ videoSource = source;
380
+ this._trackPromises.push((async () => {
381
+ await this._started;
382
+ const sink = new VideoSampleSink(track);
383
+ for await (const sample of sink.samples(this._startTimestamp, this._endTimestamp)) {
384
+ if (this._synchronizer.shouldWait(track.id, sample.timestamp)) {
385
+ await this._synchronizer.wait(sample.timestamp);
386
+ }
387
+ sample.setTimestamp(Math.max(sample.timestamp - this._startTimestamp, 0));
388
+ if (this._canceled) {
389
+ return;
390
+ }
391
+ await source.add(sample);
392
+ sample.close();
393
+ }
394
+ source.close();
395
+ this._synchronizer.closeTrack(track.id);
396
+ })());
397
+ }
398
+ }
399
+ this.output.addVideoTrack(videoSource, {
400
+ languageCode: track.languageCode,
401
+ rotation: needsRerender ? 0 : totalRotation, // Rerendering will bake the rotation into the output
402
+ });
403
+ this._addedCounts.video++;
404
+ this._totalTrackCount++;
405
+ this.utilizedTracks.push(track);
406
+ }
407
+ /** @internal */
408
+ async _processAudioTrack(track) {
409
+ const sourceCodec = track.codec;
410
+ if (!sourceCodec) {
411
+ this.discardedTracks.push({
412
+ track,
413
+ reason: 'unknown_source_codec',
414
+ });
415
+ return;
416
+ }
417
+ let audioSource;
418
+ const originalNumberOfChannels = track.numberOfChannels;
419
+ const originalSampleRate = track.sampleRate;
420
+ const firstTimestamp = await track.getFirstTimestamp();
421
+ let numberOfChannels = this._options.audio?.numberOfChannels ?? originalNumberOfChannels;
422
+ let sampleRate = this._options.audio?.sampleRate ?? originalSampleRate;
423
+ let needsResample = numberOfChannels !== originalNumberOfChannels
424
+ || sampleRate !== originalSampleRate
425
+ || this._startTimestamp > 0
426
+ || firstTimestamp < 0;
427
+ let audioCodecs = this.output.format.getSupportedAudioCodecs();
428
+ if (!this._options.audio?.forceTranscode
429
+ && !this._options.audio?.bitrate
430
+ && !needsResample
431
+ && audioCodecs.includes(sourceCodec)
432
+ && (!this._options.audio?.codec || this._options.audio.codec === sourceCodec)) {
433
+ // Fast path, we can simply copy over the encoded packets
434
+ const source = new EncodedAudioPacketSource(sourceCodec);
435
+ audioSource = source;
436
+ this._trackPromises.push((async () => {
437
+ await this._started;
438
+ const sink = new EncodedPacketSink(track);
439
+ const decoderConfig = await track.getDecoderConfig();
440
+ const meta = { decoderConfig: decoderConfig ?? undefined };
441
+ const endPacket = Number.isFinite(this._endTimestamp)
442
+ ? await sink.getPacket(this._endTimestamp, { metadataOnly: true }) ?? undefined
443
+ : undefined;
444
+ for await (const packet of sink.packets(undefined, endPacket)) {
445
+ if (this._synchronizer.shouldWait(track.id, packet.timestamp)) {
446
+ await this._synchronizer.wait(packet.timestamp);
447
+ }
448
+ if (this._canceled) {
449
+ return;
450
+ }
451
+ await source.add(packet, meta);
452
+ this._reportProgress(track.id, packet.timestamp + packet.duration);
453
+ }
454
+ source.close();
455
+ this._synchronizer.closeTrack(track.id);
456
+ })());
457
+ }
458
+ else {
459
+ // We need to decode & reencode the audio
460
+ const canDecode = await track.canDecode();
461
+ if (!canDecode) {
462
+ this.discardedTracks.push({
463
+ track,
464
+ reason: 'undecodable_source_codec',
465
+ });
466
+ return;
467
+ }
468
+ let codecOfChoice = null;
469
+ if (this._options.audio?.codec) {
470
+ audioCodecs = audioCodecs.filter(codec => codec === this._options.audio.codec);
471
+ }
472
+ const bitrate = this._options.audio?.bitrate ?? QUALITY_HIGH;
473
+ const encodableCodecs = await getEncodableAudioCodecs(audioCodecs, {
474
+ numberOfChannels,
475
+ sampleRate,
476
+ bitrate,
477
+ });
478
+ if (!encodableCodecs.some(codec => NON_PCM_AUDIO_CODECS.includes(codec))
479
+ && audioCodecs.some(codec => NON_PCM_AUDIO_CODECS.includes(codec))
480
+ && (numberOfChannels !== FALLBACK_NUMBER_OF_CHANNELS || sampleRate !== FALLBACK_SAMPLE_RATE)) {
481
+ // We could not find a compatible non-PCM codec despite the container supporting them. This can be
482
+ // caused by strange channel count or sample rate configurations. Therefore, let's try again but with
483
+ // fallback parameters.
484
+ const encodableCodecsWithDefaultParams = await getEncodableAudioCodecs(audioCodecs, {
485
+ numberOfChannels: FALLBACK_NUMBER_OF_CHANNELS,
486
+ sampleRate: FALLBACK_SAMPLE_RATE,
487
+ bitrate,
488
+ });
489
+ const nonPcmCodec = encodableCodecsWithDefaultParams
490
+ .find(codec => NON_PCM_AUDIO_CODECS.includes(codec));
491
+ if (nonPcmCodec) {
492
+ // We are able to encode using a non-PCM codec, but it'll require resampling
493
+ needsResample = true;
494
+ codecOfChoice = nonPcmCodec;
495
+ numberOfChannels = FALLBACK_NUMBER_OF_CHANNELS;
496
+ sampleRate = FALLBACK_SAMPLE_RATE;
497
+ }
498
+ }
499
+ else {
500
+ codecOfChoice = encodableCodecs[0] ?? null;
501
+ }
502
+ if (codecOfChoice === null) {
503
+ this.discardedTracks.push({
504
+ track,
505
+ reason: 'no_encodable_target_codec',
506
+ });
507
+ return;
508
+ }
509
+ if (needsResample) {
510
+ audioSource = this._resampleAudio(track, codecOfChoice, numberOfChannels, sampleRate, bitrate);
511
+ }
512
+ else {
513
+ const source = new AudioSampleSource({
514
+ codec: codecOfChoice,
515
+ bitrate,
516
+ onEncodedPacket: packet => this._reportProgress(track.id, packet.timestamp + packet.duration),
517
+ });
518
+ audioSource = source;
519
+ this._trackPromises.push((async () => {
520
+ await this._started;
521
+ const sink = new AudioSampleSink(track);
522
+ for await (const sample of sink.samples(undefined, this._endTimestamp)) {
523
+ if (this._synchronizer.shouldWait(track.id, sample.timestamp)) {
524
+ await this._synchronizer.wait(sample.timestamp);
525
+ }
526
+ if (this._canceled) {
527
+ return;
528
+ }
529
+ await source.add(sample);
530
+ sample.close();
531
+ }
532
+ source.close();
533
+ this._synchronizer.closeTrack(track.id);
534
+ })());
535
+ }
536
+ }
537
+ this.output.addAudioTrack(audioSource, {
538
+ languageCode: track.languageCode,
539
+ });
540
+ this._addedCounts.audio++;
541
+ this._totalTrackCount++;
542
+ this.utilizedTracks.push(track);
543
+ }
544
+ /** @internal */
545
+ _resampleAudio(track, codec, targetNumberOfChannels, targetSampleRate, bitrate) {
546
+ const source = new AudioSampleSource({
547
+ codec,
548
+ bitrate,
549
+ onEncodedPacket: packet => this._reportProgress(track.id, packet.timestamp + packet.duration),
550
+ });
551
+ this._trackPromises.push((async () => {
552
+ await this._started;
553
+ const resampler = new AudioResampler({
554
+ sourceNumberOfChannels: track.numberOfChannels,
555
+ sourceSampleRate: track.sampleRate,
556
+ targetNumberOfChannels,
557
+ targetSampleRate,
558
+ startTime: this._startTimestamp,
559
+ endTime: this._endTimestamp,
560
+ onSample: sample => source.add(sample),
561
+ });
562
+ const sink = new AudioSampleSink(track);
563
+ const iterator = sink.samples(this._startTimestamp, this._endTimestamp);
564
+ for await (const sample of iterator) {
565
+ if (this._synchronizer.shouldWait(track.id, sample.timestamp)) {
566
+ await this._synchronizer.wait(sample.timestamp);
567
+ }
568
+ if (this._canceled) {
569
+ return;
570
+ }
571
+ await resampler.add(sample);
572
+ }
573
+ await resampler.finalize();
574
+ source.close();
575
+ this._synchronizer.closeTrack(track.id);
576
+ })());
577
+ return source;
578
+ }
579
+ /** @internal */
580
+ _reportProgress(trackId, endTimestamp) {
581
+ if (!this._computeProgress) {
582
+ return;
583
+ }
584
+ assert(this._totalDuration !== null);
585
+ this._maxTimestamps.set(trackId, Math.max(endTimestamp, this._maxTimestamps.get(trackId) ?? -Infinity));
586
+ let totalTimestamps = 0;
587
+ for (const [, timestamp] of this._maxTimestamps) {
588
+ totalTimestamps += timestamp;
589
+ }
590
+ const averageTimestamp = totalTimestamps / this._totalTrackCount;
591
+ const newProgress = clamp(averageTimestamp / this._totalDuration, 0, 1);
592
+ if (newProgress !== this._lastProgress) {
593
+ this._lastProgress = newProgress;
594
+ this.onProgress?.(newProgress);
595
+ }
596
+ }
597
+ }
598
+ const MAX_TIMESTAMP_GAP = 5;
599
+ /**
600
+ * Utility class for synchronizing multiple track packet consumers with one another. We don't want one consumer to get
601
+ * too out-of-sync with the others, as that may lead to a large number of packets that need to be internally buffered
602
+ * before they can be written. Therefore, we use this class to slow down a consumer if it is too far ahead of the
603
+ * slowest consumer.
604
+ */
605
+ class TrackSynchronizer {
606
+ constructor() {
607
+ this.maxTimestamps = new Map(); // Track ID -> timestamp
608
+ this.resolvers = [];
609
+ }
610
+ computeMinAndMaybeResolve() {
611
+ let newMin = Infinity;
612
+ for (const [, timestamp] of this.maxTimestamps) {
613
+ newMin = Math.min(newMin, timestamp);
614
+ }
615
+ for (let i = 0; i < this.resolvers.length; i++) {
616
+ const entry = this.resolvers[i];
617
+ if (entry.timestamp - newMin < MAX_TIMESTAMP_GAP) {
618
+ // The gap has gotten small enough again, the consumer can continue again
619
+ entry.resolve();
620
+ this.resolvers.splice(i, 1);
621
+ i--;
622
+ }
623
+ }
624
+ return newMin;
625
+ }
626
+ shouldWait(trackId, timestamp) {
627
+ this.maxTimestamps.set(trackId, Math.max(timestamp, this.maxTimestamps.get(trackId) ?? -Infinity));
628
+ const newMin = this.computeMinAndMaybeResolve();
629
+ return timestamp - newMin >= MAX_TIMESTAMP_GAP; // Should wait if it is too far ahead of the slowest consumer
630
+ }
631
+ wait(timestamp) {
632
+ const { promise, resolve } = promiseWithResolvers();
633
+ this.resolvers.push({
634
+ timestamp,
635
+ resolve,
636
+ });
637
+ return promise;
638
+ }
639
+ closeTrack(trackId) {
640
+ this.maxTimestamps.delete(trackId);
641
+ this.computeMinAndMaybeResolve();
642
+ }
643
+ }
644
+ /**
645
+ * Utility class to handle audio resampling, handling both sample rate resampling as well as channel up/downmixing.
646
+ * The advantage over doing this manually rather than using OfflineAudioContext to do it for us is the artifact-free
647
+ * handling of putting multiple resampled audio samples back to back, which produces flaky results using
648
+ * OfflineAudioContext.
649
+ */
650
+ export class AudioResampler {
651
+ constructor(options) {
652
+ this.sourceSampleRate = options.sourceSampleRate;
653
+ this.targetSampleRate = options.targetSampleRate;
654
+ this.sourceNumberOfChannels = options.sourceNumberOfChannels;
655
+ this.targetNumberOfChannels = options.targetNumberOfChannels;
656
+ this.startTime = options.startTime;
657
+ this.endTime = options.endTime;
658
+ this.onSample = options.onSample;
659
+ this.bufferSizeInFrames = Math.floor(this.targetSampleRate * 5.0); // 5 seconds
660
+ this.bufferSizeInSamples = this.bufferSizeInFrames * this.targetNumberOfChannels;
661
+ this.outputBuffer = new Float32Array(this.bufferSizeInSamples);
662
+ this.bufferStartFrame = 0;
663
+ this.maxWrittenFrame = -1;
664
+ this.setupChannelMixer();
665
+ // Pre-allocate temporary buffer for source data
666
+ this.tempSourceBuffer = new Float32Array(this.sourceSampleRate * this.sourceNumberOfChannels);
667
+ }
668
+ /**
669
+ * Sets up the channel mixer to handle up/downmixing in the case where input and output channel counts don't match.
670
+ */
671
+ setupChannelMixer() {
672
+ const sourceNum = this.sourceNumberOfChannels;
673
+ const targetNum = this.targetNumberOfChannels;
674
+ // Logic taken from
675
+ // https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Basic_concepts_behind_Web_Audio_API
676
+ // Most of the mapping functions are branchless.
677
+ if (sourceNum === 1 && targetNum === 2) {
678
+ // Mono to Stereo: M -> L, M -> R
679
+ this.channelMixer = (sourceData, sourceFrameIndex) => {
680
+ return sourceData[sourceFrameIndex * sourceNum];
681
+ };
682
+ }
683
+ else if (sourceNum === 1 && targetNum === 4) {
684
+ // Mono to Quad: M -> L, M -> R, 0 -> SL, 0 -> SR
685
+ this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
686
+ return sourceData[sourceFrameIndex * sourceNum] * +(targetChannelIndex < 2);
687
+ };
688
+ }
689
+ else if (sourceNum === 1 && targetNum === 6) {
690
+ // Mono to 5.1: 0 -> L, 0 -> R, M -> C, 0 -> LFE, 0 -> SL, 0 -> SR
691
+ this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
692
+ return sourceData[sourceFrameIndex * sourceNum] * +(targetChannelIndex === 2);
693
+ };
694
+ }
695
+ else if (sourceNum === 2 && targetNum === 1) {
696
+ // Stereo to Mono: 0.5 * (L + R)
697
+ this.channelMixer = (sourceData, sourceFrameIndex) => {
698
+ const baseIdx = sourceFrameIndex * sourceNum;
699
+ return 0.5 * (sourceData[baseIdx] + sourceData[baseIdx + 1]);
700
+ };
701
+ }
702
+ else if (sourceNum === 2 && targetNum === 4) {
703
+ // Stereo to Quad: L -> L, R -> R, 0 -> SL, 0 -> SR
704
+ this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
705
+ return sourceData[sourceFrameIndex * sourceNum + targetChannelIndex] * +(targetChannelIndex < 2);
706
+ };
707
+ }
708
+ else if (sourceNum === 2 && targetNum === 6) {
709
+ // Stereo to 5.1: L -> L, R -> R, 0 -> C, 0 -> LFE, 0 -> SL, 0 -> SR
710
+ this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
711
+ return sourceData[sourceFrameIndex * sourceNum + targetChannelIndex] * +(targetChannelIndex < 2);
712
+ };
713
+ }
714
+ else if (sourceNum === 4 && targetNum === 1) {
715
+ // Quad to Mono: 0.25 * (L + R + SL + SR)
716
+ this.channelMixer = (sourceData, sourceFrameIndex) => {
717
+ const baseIdx = sourceFrameIndex * sourceNum;
718
+ return 0.25 * (sourceData[baseIdx] + sourceData[baseIdx + 1]
719
+ + sourceData[baseIdx + 2] + sourceData[baseIdx + 3]);
720
+ };
721
+ }
722
+ else if (sourceNum === 4 && targetNum === 2) {
723
+ // Quad to Stereo: 0.5 * (L + SL), 0.5 * (R + SR)
724
+ this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
725
+ const baseIdx = sourceFrameIndex * sourceNum;
726
+ return 0.5 * (sourceData[baseIdx + targetChannelIndex]
727
+ + sourceData[baseIdx + targetChannelIndex + 2]);
728
+ };
729
+ }
730
+ else if (sourceNum === 4 && targetNum === 6) {
731
+ // Quad to 5.1: L -> L, R -> R, 0 -> C, 0 -> LFE, SL -> SL, SR -> SR
732
+ this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
733
+ const baseIdx = sourceFrameIndex * sourceNum;
734
+ // It's a bit harder to do this one branchlessly
735
+ if (targetChannelIndex < 2)
736
+ return sourceData[baseIdx + targetChannelIndex]; // L, R
737
+ if (targetChannelIndex === 2 || targetChannelIndex === 3)
738
+ return 0; // C, LFE
739
+ return sourceData[baseIdx + targetChannelIndex - 2]; // SL, SR
740
+ };
741
+ }
742
+ else if (sourceNum === 6 && targetNum === 1) {
743
+ // 5.1 to Mono: sqrt(1/2) * (L + R) + C + 0.5 * (SL + SR)
744
+ this.channelMixer = (sourceData, sourceFrameIndex) => {
745
+ const baseIdx = sourceFrameIndex * sourceNum;
746
+ return Math.SQRT1_2 * (sourceData[baseIdx] + sourceData[baseIdx + 1])
747
+ + sourceData[baseIdx + 2]
748
+ + 0.5 * (sourceData[baseIdx + 4] + sourceData[baseIdx + 5]);
749
+ };
750
+ }
751
+ else if (sourceNum === 6 && targetNum === 2) {
752
+ // 5.1 to Stereo: L + sqrt(1/2) * (C + SL), R + sqrt(1/2) * (C + SR)
753
+ this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
754
+ const baseIdx = sourceFrameIndex * sourceNum;
755
+ return sourceData[baseIdx + targetChannelIndex]
756
+ + Math.SQRT1_2 * (sourceData[baseIdx + 2] + sourceData[baseIdx + targetChannelIndex + 4]);
757
+ };
758
+ }
759
+ else if (sourceNum === 6 && targetNum === 4) {
760
+ // 5.1 to Quad: L + sqrt(1/2) * C, R + sqrt(1/2) * C, SL, SR
761
+ this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
762
+ const baseIdx = sourceFrameIndex * sourceNum;
763
+ // It's a bit harder to do this one branchlessly
764
+ if (targetChannelIndex < 2) {
765
+ return sourceData[baseIdx + targetChannelIndex] + Math.SQRT1_2 * sourceData[baseIdx + 2];
766
+ }
767
+ return sourceData[baseIdx + targetChannelIndex + 2]; // SL, SR
768
+ };
769
+ }
770
+ else {
771
+ // Discrete fallback: direct mapping with zero-fill or drop
772
+ this.channelMixer = (sourceData, sourceFrameIndex, targetChannelIndex) => {
773
+ return targetChannelIndex < sourceNum
774
+ ? sourceData[sourceFrameIndex * sourceNum + targetChannelIndex]
775
+ : 0;
776
+ };
777
+ }
778
+ }
779
+ ensureTempBufferSize(requiredSamples) {
780
+ let length = this.tempSourceBuffer.length;
781
+ while (length < requiredSamples) {
782
+ length *= 2;
783
+ }
784
+ if (length !== this.tempSourceBuffer.length) {
785
+ const newBuffer = new Float32Array(length);
786
+ newBuffer.set(this.tempSourceBuffer);
787
+ this.tempSourceBuffer = newBuffer;
788
+ }
789
+ }
790
+ async add(audioSample) {
791
+ if (!audioSample || audioSample._closed) {
792
+ return;
793
+ }
794
+ const requiredSamples = audioSample.numberOfFrames * audioSample.numberOfChannels;
795
+ this.ensureTempBufferSize(requiredSamples);
796
+ // Copy the audio data to the temp buffer
797
+ const sourceDataSize = audioSample.allocationSize({ planeIndex: 0, format: 'f32' });
798
+ const sourceView = new Float32Array(this.tempSourceBuffer.buffer, 0, sourceDataSize / 4);
799
+ audioSample.copyTo(sourceView, { planeIndex: 0, format: 'f32' });
800
+ const inputStartTime = audioSample.timestamp - this.startTime;
801
+ const inputDuration = audioSample.numberOfFrames / this.sourceSampleRate;
802
+ const inputEndTime = Math.min(inputStartTime + inputDuration, this.endTime - this.startTime);
803
+ // Compute which output frames are affected by this sample
804
+ const outputStartFrame = Math.floor(inputStartTime * this.targetSampleRate);
805
+ const outputEndFrame = Math.ceil(inputEndTime * this.targetSampleRate);
806
+ for (let outputFrame = outputStartFrame; outputFrame < outputEndFrame; outputFrame++) {
807
+ if (outputFrame < this.bufferStartFrame) {
808
+ continue; // Skip writes to the past
809
+ }
810
+ while (outputFrame >= this.bufferStartFrame + this.bufferSizeInFrames) {
811
+ // The write is after the current buffer, so finalize it
812
+ await this.finalizeCurrentBuffer();
813
+ this.bufferStartFrame += this.bufferSizeInFrames;
814
+ }
815
+ const bufferFrameIndex = outputFrame - this.bufferStartFrame;
816
+ assert(bufferFrameIndex < this.bufferSizeInFrames);
817
+ const outputTime = outputFrame / this.targetSampleRate;
818
+ const inputTime = outputTime - inputStartTime;
819
+ const sourcePosition = inputTime * this.sourceSampleRate;
820
+ const sourceLowerFrame = Math.floor(sourcePosition);
821
+ const sourceUpperFrame = Math.ceil(sourcePosition);
822
+ const fraction = sourcePosition - sourceLowerFrame;
823
+ // Process each output channel
824
+ for (let targetChannel = 0; targetChannel < this.targetNumberOfChannels; targetChannel++) {
825
+ let lowerSample = 0;
826
+ let upperSample = 0;
827
+ if (sourceLowerFrame >= 0 && sourceLowerFrame < audioSample.numberOfFrames) {
828
+ lowerSample = this.channelMixer(sourceView, sourceLowerFrame, targetChannel);
829
+ }
830
+ if (sourceUpperFrame >= 0 && sourceUpperFrame < audioSample.numberOfFrames) {
831
+ upperSample = this.channelMixer(sourceView, sourceUpperFrame, targetChannel);
832
+ }
833
+ // For resampling, we do naive linear interpolation to find the in-between sample. This produces
834
+ // suboptimal results especially for downsampling (for which a low-pass filter would first need to be
835
+ // applied), but AudioContext doesn't do this either, so, whatever, for now.
836
+ const outputSample = lowerSample + fraction * (upperSample - lowerSample);
837
+ // Write to output buffer (interleaved)
838
+ const outputIndex = bufferFrameIndex * this.targetNumberOfChannels + targetChannel;
839
+ this.outputBuffer[outputIndex] += outputSample; // Add in case of overlapping samples
840
+ }
841
+ this.maxWrittenFrame = Math.max(this.maxWrittenFrame, bufferFrameIndex);
842
+ }
843
+ }
844
+ async finalizeCurrentBuffer() {
845
+ if (this.maxWrittenFrame < 0) {
846
+ return; // Nothing to finalize
847
+ }
848
+ const samplesWritten = (this.maxWrittenFrame + 1) * this.targetNumberOfChannels;
849
+ const outputData = new Float32Array(samplesWritten);
850
+ outputData.set(this.outputBuffer.subarray(0, samplesWritten));
851
+ const timestampSeconds = this.bufferStartFrame / this.targetSampleRate;
852
+ const audioSample = new AudioSample({
853
+ format: 'f32',
854
+ sampleRate: this.targetSampleRate,
855
+ numberOfChannels: this.targetNumberOfChannels,
856
+ timestamp: timestampSeconds,
857
+ data: outputData,
858
+ });
859
+ await this.onSample(audioSample);
860
+ this.outputBuffer.fill(0);
861
+ this.maxWrittenFrame = -1;
862
+ }
863
+ finalize() {
864
+ return this.finalizeCurrentBuffer();
865
+ }
866
+ }