@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.
- package/CHANGELOG.md +16 -0
- package/assets/calibration/audio/NOTICE.md +19 -0
- package/assets/calibration/audio/aac.m4a +0 -0
- package/assets/calibration/audio/ac3.ac3 +0 -0
- package/assets/calibration/audio/alac.m4a +0 -0
- package/assets/calibration/audio/dts.dts +0 -0
- package/assets/calibration/audio/eac3.eac3 +0 -0
- package/assets/calibration/audio/flac.flac +0 -0
- package/assets/calibration/audio/manifest.json +98 -0
- package/assets/calibration/audio/mp2.mp2 +0 -0
- package/assets/calibration/audio/mp3.mp3 +0 -0
- package/assets/calibration/audio/opus.opus +0 -0
- package/assets/calibration/audio/pcm.wav +0 -0
- package/assets/calibration/audio/truehd.thd +0 -0
- package/assets/calibration/audio/vorbis.ogg +0 -0
- package/biome.json +1 -0
- package/docs/browser-proxy-contract.md +155 -133
- package/docs/playback-start-forecast.md +56 -0
- package/package.json +1 -1
- package/routes/api/transcode-sessions/post.js +5 -0
- package/routes/transcode/audio-warm/get.js +14 -3
- package/scripts/build-audio-samples.mjs +31 -0
- package/server.js +5 -0
- package/services/encode/EncodeRuns.js +3 -1
- package/services/encode/OutputOpening.js +40 -15
- package/services/encode/Renditions.js +129 -31
- package/services/encode/args.js +5 -3
- package/services/encode/audio-calibration.js +67 -0
- package/services/encode/audio-work-rate.js +15 -0
- package/services/encode/hwaccel.js +1 -1
- package/services/encode/quality/EncodeCost.js +29 -3
- package/services/encode/quality/link-budget.js +110 -15
- package/services/encode/quality/output-format.js +10 -6
- package/services/encode/segment-formats/fmp4.js +2 -0
- package/services/encode/segment-formats/mp4-boxes.js +12 -1
- package/services/media/audio-inventory.js +60 -6
- package/services/media/container/Container.js +7 -1
- package/services/media/container/Mp4Container.js +103 -3
- package/services/media/tracks/AudioTrack.js +12 -0
- package/services/media/tracks/audio-bound.js +233 -0
- package/services/server/ViewerRequests.js +30 -5
- package/services/server/playback-readiness.js +516 -708
- package/services/server/wire-outputs.js +31 -3
- package/services/storage/piece-store/shared-piece-store.js +6 -0
- package/services/storage/segment-store/SegmentStore.js +19 -4
- package/services/torrent/episode-naming.js +182 -182
- package/services/torrent/torrent-pool.js +13 -0
- package/test/audio-buffer-continuity.test.js +70 -70
- package/test/audio-calibration.test.js +23 -0
- package/test/episode-naming.test.js +131 -131
- package/test/fixtures/audio-frame-boundary.json +1 -1
- package/test/fmp4-presentation-clock.test.js +17 -2
- package/test/link-budget.test.js +7 -6
- package/test/one-output-one-session.test.js +77 -8
- package/test/playback-readiness.test.js +36 -6
- package/test/quality-variants.test.js +36 -8
- package/test/segment-store.test.js +15 -0
- package/test/serving-existing-output.test.js +3 -0
- package/test/soundtrack-rate-bound.test.js +289 -0
- package/test/source-files-route.test.js +113 -113
- 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
|
|
14
|
-
*
|
|
15
|
-
* 2. `estimated` —
|
|
16
|
-
*
|
|
17
|
-
* built on it is admitted only by
|
|
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
|
|
146
|
+
* The soundtrack part of a load: the track THIS viewer receives, in the mode
|
|
147
|
+
* it is actually produced in.
|
|
146
148
|
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
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
|
|
153
|
-
if (
|
|
164
|
+
export function soundtrackLoadOf(entry, transcode) {
|
|
165
|
+
if (transcode === null || transcode === undefined) {
|
|
154
166
|
return null;
|
|
155
167
|
}
|
|
156
|
-
if (
|
|
157
|
-
return part(
|
|
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
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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,
|
|
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)
|
|
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
|
-
|
|
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
|
/**
|