@torrent-tv/proxy 2.88.3 → 2.88.5

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 (61) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/assets/calibration/audio/NOTICE.md +19 -0
  3. package/assets/calibration/audio/aac.m4a +0 -0
  4. package/assets/calibration/audio/ac3.ac3 +0 -0
  5. package/assets/calibration/audio/alac.m4a +0 -0
  6. package/assets/calibration/audio/dts.dts +0 -0
  7. package/assets/calibration/audio/eac3.eac3 +0 -0
  8. package/assets/calibration/audio/flac.flac +0 -0
  9. package/assets/calibration/audio/manifest.json +98 -0
  10. package/assets/calibration/audio/mp2.mp2 +0 -0
  11. package/assets/calibration/audio/mp3.mp3 +0 -0
  12. package/assets/calibration/audio/opus.opus +0 -0
  13. package/assets/calibration/audio/pcm.wav +0 -0
  14. package/assets/calibration/audio/truehd.thd +0 -0
  15. package/assets/calibration/audio/vorbis.ogg +0 -0
  16. package/biome.json +1 -0
  17. package/docs/browser-proxy-contract.md +155 -133
  18. package/docs/playback-start-forecast.md +56 -0
  19. package/package.json +1 -1
  20. package/routes/api/transcode-sessions/post.js +5 -0
  21. package/routes/transcode/audio-warm/get.js +14 -3
  22. package/scripts/build-audio-samples.mjs +31 -0
  23. package/server.js +5 -0
  24. package/services/encode/EncodeRuns.js +3 -1
  25. package/services/encode/OutputOpening.js +40 -15
  26. package/services/encode/Renditions.js +129 -31
  27. package/services/encode/args.js +5 -3
  28. package/services/encode/audio-calibration.js +67 -0
  29. package/services/encode/audio-work-rate.js +15 -0
  30. package/services/encode/hwaccel.js +1 -1
  31. package/services/encode/quality/EncodeCost.js +29 -3
  32. package/services/encode/quality/link-budget.js +110 -15
  33. package/services/encode/quality/output-format.js +10 -6
  34. package/services/encode/segment-formats/fmp4.js +2 -0
  35. package/services/encode/segment-formats/mp4-boxes.js +12 -1
  36. package/services/media/audio-inventory.js +60 -6
  37. package/services/media/container/Container.js +7 -1
  38. package/services/media/container/Mp4Container.js +103 -3
  39. package/services/media/tracks/AudioTrack.js +12 -0
  40. package/services/media/tracks/audio-bound.js +233 -0
  41. package/services/server/ViewerRequests.js +30 -5
  42. package/services/server/playback-readiness.js +516 -708
  43. package/services/server/wire-outputs.js +31 -3
  44. package/services/storage/piece-store/shared-piece-store.js +6 -0
  45. package/services/storage/segment-store/SegmentStore.js +19 -4
  46. package/services/torrent/episode-naming.js +182 -182
  47. package/services/torrent/torrent-pool.js +13 -0
  48. package/test/audio-buffer-continuity.test.js +70 -70
  49. package/test/audio-calibration.test.js +23 -0
  50. package/test/episode-naming.test.js +131 -131
  51. package/test/fixtures/audio-frame-boundary.json +1 -1
  52. package/test/fmp4-presentation-clock.test.js +17 -2
  53. package/test/link-budget.test.js +7 -6
  54. package/test/one-output-one-session.test.js +77 -8
  55. package/test/playback-readiness.test.js +36 -6
  56. package/test/quality-variants.test.js +36 -8
  57. package/test/segment-store.test.js +15 -0
  58. package/test/serving-existing-output.test.js +3 -0
  59. package/test/soundtrack-rate-bound.test.js +289 -0
  60. package/test/source-files-route.test.js +113 -113
  61. package/test/two-viewers-one-picture.test.js +97 -0
@@ -10,18 +10,19 @@
10
10
  * picture's own stream, or as a separate one — so both are added before the
11
11
  * link is asked. And each part says how much its figure can be trusted:
12
12
  *
13
- * 1. `known` — a bound the encoder is held to: a re-encode's `maxrate`, or the
14
- * constant rate a re-encoded soundtrack is produced at;
15
- * 2. `estimated` — a figure the FILE states: a copied picture's average rate,
16
- * a copied soundtrack's stated rate. An average is not a bound, so a load
17
- * built on it is admitted only by estimate and is never confirmed;
13
+ * 1. `known` — a bound the stream is held to: a re-encode's `maxrate`, or the
14
+ * most a copied soundtrack's codec allows it to carry;
15
+ * 2. `estimated` — an average: a copied picture's or soundtrack's rate as the
16
+ * FILE states it, or the target a re-encoded soundtrack is asked for. An
17
+ * average is not a bound, so a load built on it is admitted only by
18
+ * estimate and is never confirmed;
18
19
  * 3. `unknown` — no figure at all: a hardware encoder given no limit, a copied
19
20
  * soundtrack whose rate nothing states.
20
21
  *
21
22
  * A load is as trustworthy as its least trustworthy part.
22
23
  */
23
24
 
24
- import { maxrateKbpsFor, nominalKbpsFor } from "../args.js";
25
+ import { AUDIO_TRANSCODE_KBPS, maxrateKbpsFor, nominalKbpsFor } from "../args.js";
25
26
 
26
27
  /**
27
28
  * How much of a measured link may be spent on the stream.
@@ -142,23 +143,93 @@ export function videoLoadOfLimit(nominalKbps) {
142
143
  }
143
144
 
144
145
  /**
145
- * The soundtrack part of a load: the track THIS viewer chose.
146
+ * The soundtrack part of a load: the track THIS viewer receives, in the mode
147
+ * it is actually produced in.
146
148
  *
147
- * @param {{ transcode: boolean, bitrateKbps: number | null } | null} track -
148
- * Null when the viewer receives no sound from this output or beside it.
149
- * @param {number} transcodeKbps - What a re-encoded track is produced at.
149
+ * Copied, the track weighs what its codec allows it to — the bound its
150
+ * configuration is held to, `known` — and where no such bound is confirmed, the
151
+ * average the file states for it, `estimated`. Re-encoded, it weighs the rate
152
+ * the encoder is asked for, and that is `estimated` too: ffmpeg's AAC encoder
153
+ * treats `-b:a` as a target its rate control drifts towards, not a limit
154
+ * (`libavcodec/aacenc.c`, 8.1.2), and the 6144-bit frame limit it does hold
155
+ * cannot be turned into a rate without the output's sampling frequency, which
156
+ * the command does not fix. `research/soundtrack-rate-bound-2026-10-01.md`.
157
+ *
158
+ * @param {import("../../media/audio-inventory.js").AudioInventoryEntry | null | undefined} entry -
159
+ * The inventory entry of the track; null when nothing describes it.
160
+ * @param {boolean | null} transcode - How it reaches the viewer; null when the
161
+ * viewer receives no sound from this output or beside it.
150
162
  * @returns {LoadPart | null}
151
163
  */
152
- export function audioLoadOf(track, transcodeKbps) {
153
- if (!track) {
164
+ export function soundtrackLoadOf(entry, transcode) {
165
+ if (transcode === null || transcode === undefined) {
154
166
  return null;
155
167
  }
156
- if (track.transcode === true) {
157
- return part(transcodeKbps / 1000, PEAK_CLASS.KNOWN);
168
+ if (transcode === true) {
169
+ return part(AUDIO_TRANSCODE_KBPS / 1000, PEAK_CLASS.ESTIMATED);
170
+ }
171
+ const peakKbps = Number(entry?.peakKbps);
172
+ if (Number.isFinite(peakKbps) && peakKbps > 0) {
173
+ return part(peakKbps / 1000, PEAK_CLASS.KNOWN);
174
+ }
175
+ return part((Number(entry?.bitrateKbps) || 0) / 1000, PEAK_CLASS.ESTIMATED);
176
+ }
177
+
178
+ /**
179
+ * Whether a copied track has a figure for the link to be asked about: a
180
+ * confirmed bound, or a rate the file states.
181
+ *
182
+ * @param {import("../../media/audio-inventory.js").AudioInventoryEntry | null | undefined} entry
183
+ * @returns {boolean}
184
+ */
185
+ export function soundtrackHasFigure(entry) {
186
+ const peakKbps = Number(entry?.peakKbps);
187
+ const bitrateKbps = Number(entry?.bitrateKbps);
188
+ return (Number.isFinite(peakKbps) && peakKbps > 0) || (Number.isFinite(bitrateKbps) && bitrateKbps > 0);
189
+ }
190
+
191
+ /**
192
+ * How a soundtrack is produced for a viewer, decided once, when they choose it.
193
+ *
194
+ * 1. the browser plays the track as it is and the track has a figure — copied;
195
+ * 2. the browser cannot play it, or it has no figure at all — re-encoded to AAC,
196
+ * where re-encoding is allowed. Without a figure the link cannot be asked
197
+ * whether a copy fits it, and the re-encode is the soundtrack that does have
198
+ * one;
199
+ * 3. re-encoding not allowed: a track the browser plays is copied, figure or
200
+ * not, and the link's own answer about it stands; a track it cannot play
201
+ * cannot be served at all.
202
+ *
203
+ * @param {object} params
204
+ * @param {import("../../media/audio-inventory.js").AudioInventoryEntry | null | undefined} params.entry
205
+ * @param {boolean} params.browserPlays - Whether the viewer's browser can play
206
+ * this track's codec as it stands.
207
+ * @param {boolean} params.transcodeAllowed
208
+ * @returns {{ transcode: boolean | null, cause: string }} `transcode: null`
209
+ * when the track cannot be served. `cause` is one of {@link SOUNDTRACK_MODE_CAUSE}.
210
+ */
211
+ export function chooseSoundtrackMode({ entry, browserPlays, transcodeAllowed }) {
212
+ const hasFigure = soundtrackHasFigure(entry);
213
+ if (browserPlays && hasFigure) {
214
+ return { transcode: false, cause: SOUNDTRACK_MODE_CAUSE.COPY };
215
+ }
216
+ if (transcodeAllowed) {
217
+ return { transcode: true, cause: browserPlays ? SOUNDTRACK_MODE_CAUSE.NO_FIGURE : SOUNDTRACK_MODE_CAUSE.UNPLAYABLE };
158
218
  }
159
- return part((Number(track.bitrateKbps) || 0) / 1000, PEAK_CLASS.ESTIMATED);
219
+ return browserPlays
220
+ ? { transcode: false, cause: SOUNDTRACK_MODE_CAUSE.COPY_WITHOUT_FIGURE }
221
+ : { transcode: null, cause: SOUNDTRACK_MODE_CAUSE.CANNOT_SERVE };
160
222
  }
161
223
 
224
+ /** Why a soundtrack is produced the way {@link chooseSoundtrackMode} decided. */
225
+ export const SOUNDTRACK_MODE_CAUSE = Object.freeze({
226
+ COPY: "copied: the browser plays it and its rate is stated",
227
+ NO_FIGURE: "re-encoded: nothing states how much a copy of it would carry",
228
+ UNPLAYABLE: "re-encoded: the browser cannot play it as it is",
229
+ COPY_WITHOUT_FIGURE: "copied without a stated rate: re-encoding is not allowed here",
230
+ CANNOT_SERVE: "the browser cannot play it and re-encoding is not allowed here"
231
+ });
232
+
162
233
  /**
163
234
  * The whole load: the parts added, and the class of the least trustworthy.
164
235
  *
@@ -216,6 +287,30 @@ export function linkCouldCarry(linkMbps, load) {
216
287
  : answer(LINK_VERDICT.ESTIMATED_TO_FIT, true, false);
217
288
  }
218
289
 
290
+ /**
291
+ * Why a link refused a load, in words naming what was missing or too much.
292
+ *
293
+ * `no safe bound` names every part that has no figure — the picture, the
294
+ * soundtrack or both — because that, and not the link, is what the refusal is
295
+ * about. Any other refusal is about the size of the load and is described by
296
+ * the caller, who knows which outputs were weighed.
297
+ *
298
+ * @param {{ verdict: string, load: Load }} answer
299
+ * @param {string} otherwise - The words for a load that is too large.
300
+ * @returns {string}
301
+ */
302
+ export function linkRefusalReason(answer, otherwise) {
303
+ if (answer?.verdict !== LINK_VERDICT.NO_SAFE_BOUND) {
304
+ return otherwise;
305
+ }
306
+ const missing = [
307
+ answer.load?.video?.peakClass === PEAK_CLASS.UNKNOWN ? "the picture" : "",
308
+ answer.load?.audio?.peakClass === PEAK_CLASS.UNKNOWN ? "the soundtrack" : ""
309
+ ].filter(Boolean);
310
+ return `nothing states how much ${missing.length > 0 ? missing.join(" or ") : "this output"} would send, ` +
311
+ "so this viewer's link cannot be asked whether it fits";
312
+ }
313
+
219
314
  /**
220
315
  * How strongly an answer is admitted, for ordering: a load whose figure is a
221
316
  * bound before one whose figure is an average, and both before one with no
@@ -38,7 +38,7 @@ import { computeOutputDimensions, nominalKbpsFor, softwareRateControlFor } from
38
38
  import { buildResolutionLadder, canSustainOutput, pickSoftwarePreset } from "../hwaccel.js";
39
39
  import { OutputSpec } from "../output/OutputSpec.js";
40
40
  import { chooseServingOutput, nextRungAreaBelow, servingCandidates } from "./serving-output.js";
41
- import { linkAnswerFigures, linkCouldCarry, loadOf, videoLoadOfSpec } from "./link-budget.js";
41
+ import { linkAnswerFigures, linkCouldCarry, linkRefusalReason, loadOf, videoLoadOfSpec } from "./link-budget.js";
42
42
 
43
43
  /**
44
44
  * @param {object} params
@@ -124,7 +124,7 @@ export function decideOutputFormat({
124
124
  const answer = judge(spec);
125
125
  return answer.admitted
126
126
  ? given(spec, answer)
127
- : refused(spec, answer, "the source's own picture is more than this viewer's link carries");
127
+ : refused(spec, answer, linkRefusalReason(answer, "the source's own picture is more than this viewer's link carries"));
128
128
  }
129
129
 
130
130
  const ceiling = computeOutputDimensions(target.width, target.height, source.width, source.height);
@@ -304,12 +304,16 @@ export function decideOutputFormat({
304
304
  }
305
305
  };
306
306
  }
307
+ const wantedAnswer = judge(wantedSpec);
307
308
  return refused(
308
309
  wantedSpec,
309
- judge(wantedSpec),
310
- serving.mode === "manual"
311
- ? "no limit of the size picked is admitted by this viewer's link"
312
- : "no size and limit this viewer's ladder offers is admitted by their link",
310
+ wantedAnswer,
311
+ linkRefusalReason(
312
+ wantedAnswer,
313
+ serving.mode === "manual"
314
+ ? "no limit of the size picked is admitted by this viewer's link"
315
+ : "no size and limit this viewer's ladder offers is admitted by their link"
316
+ ),
313
317
  { budget, wantedKey }
314
318
  );
315
319
  }
@@ -14,6 +14,7 @@ import {
14
14
  continuePresentationRanges,
15
15
  rebaseSegmentDecodeTimes,
16
16
  neutralizeEmptyEdits,
17
+ presentationCoverageEnd,
17
18
  readPresentationRanges,
18
19
  readSelfContainedStartSeconds,
19
20
  readTrackTimescales,
@@ -281,6 +282,7 @@ export const fmp4Format = {
281
282
  * transcode itself; the box walk never descends into `mdat`.
282
283
  */
283
284
  needsSegmentRewrite: true,
285
+ mediaCoverageEnd: presentationCoverageEnd,
284
286
  readMediaRanges(raw, { initBytes = null, startSeconds = 0 } = {}) {
285
287
  if (this.extractInit(raw)) return readPresentationRanges(raw);
286
288
  if (!initBytes?.length) return undefined;
@@ -267,6 +267,14 @@ export function readPresentationRanges(raw) {
267
267
  return result;
268
268
  }
269
269
 
270
+ /** Last continuous coded-frame boundary on every track in a closed piece. */
271
+ export function presentationCoverageEnd(ranges) {
272
+ const tracks = presentationTracks.get(ranges);
273
+ if (!tracks?.length) return null;
274
+ return Math.min(...tracks.map(({ frameRanges, precision }) => Math.max(...frameRanges.map((range) =>
275
+ range.lastFrameStart + 2 * range.maxFrameDuration + precision))));
276
+ }
277
+
270
278
  /** A video frame can cross a file boundary when decoding remains continuous. */
271
279
  export function continuePresentationRanges(ranges, nextRanges) {
272
280
  const tracks = presentationTracks.get(ranges);
@@ -306,7 +314,10 @@ export function translatePresentationRanges(ranges, initBytes, timestampOffsetSe
306
314
  ranges: held.map(({ start, end }, index) => ({
307
315
  start: Math.max(0, start + timestampOffsetSeconds - (edits.get(id) ?? 0)),
308
316
  end: end + timestampOffsetSeconds - (edits.get(id) ?? 0),
309
- ...(kind === "soun" ? {
317
+ // Chromium joins continuous audio and video within twice the measured
318
+ // frame distance. This is a coded-frame rule, not an ETA tolerance.
319
+ // https://chromium.googlesource.com/chromium/src/+/refs/heads/main/media/filters/source_buffer_stream.cc
320
+ ...(["soun", "vide"].includes(kind) ? {
310
321
  joinEnd: frameRanges[index].lastFrameStart + 2 * frameRanges[index].maxFrameDuration +
311
322
  timestampOffsetSeconds - (edits.get(id) ?? 0)
312
323
  } : {})
@@ -30,6 +30,7 @@
30
30
 
31
31
 
32
32
  import { AudioTrack } from "./tracks/AudioTrack.js";
33
+ import { codecParametersComplete, codecParametersConflict, peakKbpsOf } from "./tracks/audio-bound.js";
33
34
 
34
35
 
35
36
 
@@ -57,6 +58,12 @@ import { AudioTrack } from "./tracks/AudioTrack.js";
57
58
  * @property {boolean} isEnabled
58
59
  * @property {number | null} channels
59
60
  * @property {number | null} bitrateKbps - The rate the file states for this track; an average, not a bound.
61
+ * @property {import("./tracks/audio-bound.js").CodecParameters | null} codecParameters -
62
+ * The codec's configuration as the container states it, or null where no
63
+ * track table was read.
64
+ * @property {number | null} peakKbps - The most the codec, so configured, allows
65
+ * this track to carry — a bound, not an average — or null where no confirmed
66
+ * bound exists for that configuration (`tracks/audio-bound.js`).
60
67
  * @property {string} fileName - For a sidecar: its own file name. "" otherwise.
61
68
  * @property {string[]} folders - For a sidecar: the folders above it, relative
62
69
  * to the torrent root. What the browser reads a language and a releaser from.
@@ -106,11 +113,14 @@ export function buildAudioInventory({ embedded, videoFileIndex, sidecars }) {
106
113
  isVisualImpaired: track?.isVisualImpaired === true,
107
114
  isEnabled: track?.isEnabled !== false,
108
115
  channels: Number.isFinite(track?.channels) ? track.channels : null,
116
+ samplingFrequency: Number.isFinite(track?.samplingFrequency) ? track.samplingFrequency : null,
109
117
  // The rate the file STATES for this track, in kbit/s — an average, not a
110
118
  // bound — or null where nothing states one (a sidecar read from its
111
119
  // container table, a Matroska stream without statistics tags). What a
112
120
  // viewer's link is asked to carry for a copied soundtrack is this.
113
121
  bitrateKbps: Number.isFinite(track?.bitrateKbps) && track.bitrateKbps > 0 ? track.bitrateKbps : null,
122
+ codecParameters: track?.codecParameters ?? null,
123
+ peakKbps: Number.isFinite(track?.peakKbps) && track.peakKbps > 0 ? track.peakKbps : null,
114
124
  fileName: kind === "sidecar" ? (file?.name ?? "") : "",
115
125
  folders: kind === "sidecar" && Array.isArray(file?.folders) ? file.folders : [],
116
126
  // What the file's own path says, for a track that ships as its own file:
@@ -177,15 +187,59 @@ export function enrichAudioInventoryFromSidecar(inventory, file, declaredTrack)
177
187
  videoFileIndex: -1,
178
188
  sidecars: [{ file, tracks: [declaredTrack] }]
179
189
  })[0];
180
- Object.assign(current, enriched, {
181
- index: current.index,
182
- fileIndex: current.fileIndex,
183
- sourceTrackIndex: current.sourceTrackIndex,
184
- kind: current.kind
185
- });
190
+ Object.assign(current, mergeInventoryEntry(current, enriched));
186
191
  return true;
187
192
  }
188
193
 
194
+ /**
195
+ * One entry of the inventory with what a later reading of the same track adds.
196
+ *
197
+ * Two rules, because the fields are of two kinds:
198
+ *
199
+ * 1. a single fact — a language, a title, a flag, a stated average rate — is
200
+ * taken from the new reading where it states one, and otherwise the value
201
+ * already known stands. A reading that says nothing about a field is not a
202
+ * statement that the field is empty;
203
+ * 2. the codec's configuration is one statement, and the bound is computed from
204
+ * it, so its fields are never mixed between readings. A complete set that
205
+ * agrees with what is known replaces it whole; an incomplete one that agrees
206
+ * leaves it standing; any contradiction — a field both readings state,
207
+ * stated differently — withdraws the configuration and the bound, even
208
+ * when the new reading is complete, since two readings that disagree
209
+ * establish neither. The next complete reading then stands on its own.
210
+ *
211
+ * Where the track IS — its number, its file, its position in that file and
212
+ * whether it is a sidecar — is never changed: a player addresses it by them.
213
+ *
214
+ * @param {AudioInventoryEntry} current
215
+ * @param {AudioInventoryEntry} next
216
+ * @returns {AudioInventoryEntry}
217
+ */
218
+ export function mergeInventoryEntry(current, next) {
219
+ const stated = (value) =>
220
+ value !== null && value !== undefined && !(typeof value === "string" && value.length === 0);
221
+ const merged = { ...current };
222
+ for (const [field, value] of Object.entries(next)) {
223
+ if (field === "codecParameters" || field === "peakKbps") continue;
224
+ if (stated(value)) merged[field] = value;
225
+ }
226
+ const known = current.codecParameters ?? null;
227
+ const read = next.codecParameters ?? null;
228
+ if (codecParametersConflict(known, read)) {
229
+ merged.codecParameters = null;
230
+ } else if (codecParametersComplete(read)) {
231
+ merged.codecParameters = read;
232
+ } else {
233
+ merged.codecParameters = known;
234
+ }
235
+ merged.peakKbps = peakKbpsOf(merged.codecParameters);
236
+ merged.index = current.index;
237
+ merged.fileIndex = current.fileIndex;
238
+ merged.sourceTrackIndex = current.sourceTrackIndex;
239
+ merged.kind = current.kind;
240
+ return merged;
241
+ }
242
+
189
243
  /**
190
244
  * The name an `#EXT-X-MEDIA` line carries for one soundtrack.
191
245
  *
@@ -331,6 +331,10 @@ export class Container {
331
331
  declaresDefault: track.declaresDefault === true,
332
332
  languageBcp47: typeof track.languageBcp47 === "string" ? track.languageBcp47 : "",
333
333
  channels: Number.isFinite(track.channels) ? track.channels : null,
334
+ // The codec's configuration and the most it allows the track to
335
+ // carry, both as the container states them (`tracks/audio-bound.js`).
336
+ codecParameters: track.codecParameters ?? null,
337
+ peakKbps: Number.isFinite(track.peakKbps) && track.peakKbps > 0 ? track.peakKbps : null,
334
338
  title:
335
339
  typeof banner?.title === "string" && banner.title.length > 0
336
340
  ? banner.title
@@ -343,7 +347,9 @@ export class Container {
343
347
  isVisualImpaired: false,
344
348
  isEnabled: true,
345
349
  languageBcp47: "",
346
- channels: null
350
+ channels: null,
351
+ codecParameters: null,
352
+ peakKbps: null
347
353
  }
348
354
  );
349
355
  }
@@ -88,6 +88,98 @@ function childOf(buf, s, e, t) {
88
88
  return childrenOf(buf, s, e, t)[0] ?? null;
89
89
  }
90
90
 
91
+ /**
92
+ * The channel count, sampling frequency and decoder configuration one audio
93
+ * sample entry states (ISO/IEC 14496-12 §12.2.3, AudioSampleEntry).
94
+ *
95
+ * After the 8 bytes every SampleEntry begins with come 8 reserved bytes —
96
+ * whose first two QuickTime uses as a version that lengthens the entry by 16
97
+ * (version 1) or 36 (version 2) bytes — then `channelcount`, `samplesize`, two
98
+ * reserved fields and `samplerate` as 16.16 fixed point. Child boxes follow.
99
+ * For `mp4a` the child `esds` holds the AudioSpecificConfig (ISO/IEC 14496-1
100
+ * §7.2.6: ES_Descriptor, then DecoderConfigDescriptor, then
101
+ * DecoderSpecificInfo).
102
+ *
103
+ * @param {Buffer} buf
104
+ * @param {{ type: string, dataOffset: number, end: number }} entry
105
+ * @returns {{ channels: number | null, sampleRate: number | null, decoderConfig: Buffer | null } | null}
106
+ */
107
+ function readAudioSampleEntry(buf, entry) {
108
+ const base = entry.dataOffset;
109
+ if (base + 28 > entry.end || base + 28 > buf.length) {
110
+ return null;
111
+ }
112
+ const version = buf.readUInt16BE(base + 8);
113
+ const channels = buf.readUInt16BE(base + 16);
114
+ const sampleRate = buf.readUInt32BE(base + 24) / 65536;
115
+ const childrenStart = base + 28 + (version === 1 ? 16 : version === 2 ? 36 : 0);
116
+ let decoderConfig = null;
117
+ if (entry.type === "mp4a" && childrenStart < entry.end) {
118
+ const esds = childOf(buf, childrenStart, Math.min(entry.end, buf.length), "esds");
119
+ if (esds) {
120
+ decoderConfig = decoderSpecificInfoOf(buf, esds.dataOffset + 4, Math.min(esds.end, buf.length));
121
+ }
122
+ }
123
+ return {
124
+ channels: channels > 0 ? channels : null,
125
+ sampleRate: sampleRate > 0 ? sampleRate : null,
126
+ decoderConfig
127
+ };
128
+ }
129
+
130
+ /**
131
+ * The DecoderSpecificInfo inside an `esds` payload, or null.
132
+ *
133
+ * Descriptors are a tag byte and a size of one to four bytes, seven bits each,
134
+ * the high bit saying another follows (ISO/IEC 14496-1 §8.3.3).
135
+ *
136
+ * @param {Buffer} buf
137
+ * @param {number} start
138
+ * @param {number} end
139
+ * @returns {Buffer | null}
140
+ */
141
+ function decoderSpecificInfoOf(buf, start, end) {
142
+ const descriptorAt = (offset) => {
143
+ if (offset >= end) return null;
144
+ const tag = buf[offset];
145
+ let size = 0;
146
+ let cursor = offset + 1;
147
+ for (let i = 0; i < 4 && cursor < end; i += 1) {
148
+ const byte = buf[cursor];
149
+ cursor += 1;
150
+ size = (size * 128) + (byte & 0x7f);
151
+ if ((byte & 0x80) === 0) break;
152
+ }
153
+ return { tag, dataOffset: cursor, end: Math.min(end, cursor + size) };
154
+ };
155
+ const es = descriptorAt(start);
156
+ if (!es || es.tag !== 0x03 || es.dataOffset + 3 > es.end) return null;
157
+ const flags = buf[es.dataOffset + 2];
158
+ let cursor = es.dataOffset + 3;
159
+ if (flags & 0x80) cursor += 2;
160
+ if (flags & 0x40) cursor += 1 + (buf[cursor] ?? 0);
161
+ if (flags & 0x20) cursor += 2;
162
+ while (cursor < es.end) {
163
+ const descriptor = descriptorAt(cursor);
164
+ if (!descriptor) return null;
165
+ if (descriptor.tag === 0x04) {
166
+ // objectTypeIndication, streamType, bufferSizeDB, maxBitrate, avgBitrate.
167
+ let inner = descriptor.dataOffset + 13;
168
+ while (inner < descriptor.end) {
169
+ const child = descriptorAt(inner);
170
+ if (!child) return null;
171
+ if (child.tag === 0x05) {
172
+ return child.end > child.dataOffset ? Buffer.from(buf.subarray(child.dataOffset, child.end)) : null;
173
+ }
174
+ inner = child.end;
175
+ }
176
+ return null;
177
+ }
178
+ cursor = descriptor.end;
179
+ }
180
+ return null;
181
+ }
182
+
91
183
  export class Mp4Container extends Container {
92
184
  get formatName() {
93
185
  return "mp4";
@@ -470,12 +562,16 @@ export class Mp4Container extends Container {
470
562
  } else if (handler === "soun") {
471
563
  audioIdx += 1;
472
564
  let codecId = "";
565
+ let sampleEntry = null;
473
566
  const minf = childOf(moov, mdia.dataOffset, mdia.end, "minf");
474
567
  const stbl = minf && childOf(moov, minf.dataOffset, minf.end, "stbl");
475
568
  const stsd = stbl && childOf(moov, stbl.dataOffset, stbl.end, "stsd");
476
569
  if (stsd) {
477
570
  const first = readBox(moov, stsd.dataOffset + 8);
478
- if (first) codecId = first.type;
571
+ if (first) {
572
+ codecId = first.type;
573
+ sampleEntry = readAudioSampleEntry(moov, first);
574
+ }
479
575
  }
480
576
  result.push(new AudioTrack({
481
577
  trackNumber: trackId,
@@ -487,11 +583,15 @@ export class Mp4Container extends Container {
487
583
  isEnabled,
488
584
  isDefault: true,
489
585
  declaresDefault: false,
490
- codecPrivateB64: "",
586
+ // The AudioSpecificConfig an `mp4a` entry carries in its `esds`, which
587
+ // is what Matroska writes as CodecPrivate for the same codec.
588
+ codecPrivateB64: sampleEntry?.decoderConfig ? sampleEntry.decoderConfig.toString("base64") : "",
491
589
  alternateGroup,
492
590
  isOriginal: false,
493
591
  isCommentary: false,
494
- isVisualImpaired: false
592
+ isVisualImpaired: false,
593
+ channels: sampleEntry?.channels ?? null,
594
+ samplingFrequency: sampleEntry?.sampleRate ?? null
495
595
  }));
496
596
  }
497
597
  }
@@ -10,6 +10,7 @@
10
10
  * MP4 ISO/IEC 14496-12: tkhd alternate_group groups alternate audio tracks.
11
11
  */
12
12
 
13
+ import { codecParametersOf, peakKbpsOf } from "./audio-bound.js";
13
14
  import { ContainerTrack } from "./ContainerTrack.js";
14
15
 
15
16
  /**
@@ -85,6 +86,17 @@ export class AudioTrack extends ContainerTrack {
85
86
  this.isVisualImpaired = params.isVisualImpaired === true;
86
87
  this.channels = Number.isFinite(params.channels) ? params.channels : null;
87
88
  this.samplingFrequency = Number.isFinite(params.samplingFrequency) ? params.samplingFrequency : null;
89
+ // What the codec is configured as, and the most that configuration allows
90
+ // the track to carry — a statement of the file, read with the rest of the
91
+ // track table (`audio-bound.js`). Own properties rather than getters, so a
92
+ // copy of the track made field by field keeps them.
93
+ this.codecParameters = codecParametersOf({
94
+ codec: AudioTrack.codecNameOf(params),
95
+ codecPrivateB64: params.codecPrivateB64,
96
+ channels: this.channels,
97
+ samplingFrequency: this.samplingFrequency
98
+ });
99
+ this.peakKbps = peakKbpsOf(this.codecParameters);
88
100
  }
89
101
 
90
102
  /**