@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
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Build-time only. No sample generation runs when the proxy starts. */
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const directory = fileURLToPath(new URL("../assets/calibration/audio/", import.meta.url));
|
|
8
|
+
const formats = [
|
|
9
|
+
["aac", "aac", "m4a"], ["ac3", "ac3", "ac3"], ["eac3", "eac3", "eac3"],
|
|
10
|
+
["dts", "dca", "dts"], ["flac", "flac", "flac"], ["opus", "libopus", "opus"],
|
|
11
|
+
["vorbis", "libvorbis", "ogg"], ["truehd", "truehd", "thd"], ["mp3", "libmp3lame", "mp3"],
|
|
12
|
+
["mp2", "mp2", "mp2"], ["alac", "alac", "m4a"], ["pcm", "pcm_s16le", "wav"]
|
|
13
|
+
];
|
|
14
|
+
mkdirSync(directory, { recursive: true });
|
|
15
|
+
const manifest = [];
|
|
16
|
+
for (const [codec, encoder, extension] of formats) {
|
|
17
|
+
const file = `${codec}.${extension}`;
|
|
18
|
+
const result = spawnSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y",
|
|
19
|
+
"-f", "lavfi", "-i", "anoisesrc=color=pink:seed=1:sample_rate=48000",
|
|
20
|
+
"-t", "4", "-ac", "2", "-strict", "-2", "-c:a", encoder, path.join(directory, file)],
|
|
21
|
+
{ encoding: "utf8", windowsHide: true });
|
|
22
|
+
if (result.status !== 0) throw new Error(`${codec}: ${result.stderr}`);
|
|
23
|
+
const probe = spawnSync("ffprobe", ["-v", "error", "-show_streams", "-show_format", "-of", "json",
|
|
24
|
+
path.join(directory, file)], { encoding: "utf8", windowsHide: true });
|
|
25
|
+
if (probe.status !== 0) throw new Error(`${codec}: ${probe.stderr}`);
|
|
26
|
+
const { streams, format } = JSON.parse(probe.stdout);
|
|
27
|
+
const stream = streams.find(({ codec_type }) => codec_type === "audio");
|
|
28
|
+
manifest.push({ codec, file, durationSeconds: Number(format.duration), channels: stream.channels,
|
|
29
|
+
samplingFrequency: Number(stream.sample_rate), bytes: Number(format.size) });
|
|
30
|
+
}
|
|
31
|
+
writeFileSync(path.join(directory, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
package/server.js
CHANGED
|
@@ -58,6 +58,7 @@ import { detectVideoEncoder, benchmarkDecodeCost, benchmarkContention, benchmark
|
|
|
58
58
|
import { calibrateEncoder, HostCalibration } from "./services/encode/calibration.js";
|
|
59
59
|
import { readHostFingerprint } from "./services/encode/fingerprint.js";
|
|
60
60
|
import { measureStartAndStop } from "./services/encode/start-stop-cost.js";
|
|
61
|
+
import { benchmarkAudio } from "./services/encode/audio-calibration.js";
|
|
61
62
|
import { logger } from "./utils/logger.js";
|
|
62
63
|
import { completedFilesRoot } from "./services/storage/files/CompletedFiles.js";
|
|
63
64
|
|
|
@@ -232,6 +233,9 @@ export async function startProxyServer({
|
|
|
232
233
|
const copySpeedX = transcodeAudio
|
|
233
234
|
? await benchmarkCopySpeed({ ffmpegBin, logger })
|
|
234
235
|
: null;
|
|
236
|
+
const audioCalibration = transcodeAudio
|
|
237
|
+
? await benchmarkAudio({ ffmpegBin, logger })
|
|
238
|
+
: [];
|
|
235
239
|
// WHAT A START AND A STOP COST HERE, before any viewer exists. Both decide one
|
|
236
240
|
// thing — leave an encoder where it stands, or kill it and start another —
|
|
237
241
|
// and both used to be learned only from runs that had ENDED, so at a cold
|
|
@@ -424,6 +428,7 @@ export async function startProxyServer({
|
|
|
424
428
|
localPort: selectedPort,
|
|
425
429
|
videoEncoder,
|
|
426
430
|
calibration,
|
|
431
|
+
audioCalibration,
|
|
427
432
|
decodeCostModel,
|
|
428
433
|
contentionPenalties,
|
|
429
434
|
copySpeedX,
|
|
@@ -836,7 +836,9 @@ export class EncodeRuns {
|
|
|
836
836
|
const index = session.segmentFormat.segmentIndexFromName(
|
|
837
837
|
session.segmentFormat.servedNameOf?.(name) ?? name);
|
|
838
838
|
if (Number.isInteger(index) && index < safeIndex) return null;
|
|
839
|
-
return this.#host.segmentFiles.publish(session.outputKey ?? "", name, session.segmentFormat
|
|
839
|
+
return this.#host.segmentFiles.publish(session.outputKey ?? "", name, session.segmentFormat, {
|
|
840
|
+
endSeconds: index < session.timeline.segmentCount - 1 ? session.timeline.publishedStartOf(index + 1) : undefined
|
|
841
|
+
});
|
|
840
842
|
},
|
|
841
843
|
onEnded: (ended) => this.noteRunEnded(session, run, ended)
|
|
842
844
|
});
|
|
@@ -25,8 +25,7 @@ import { formatSeconds } from "./EncodeRuns.js";
|
|
|
25
25
|
import { EncodedOutput } from "./output/EncodedOutput.js";
|
|
26
26
|
import { decideOutputFormat } from "./quality/output-format.js";
|
|
27
27
|
import { rungForVisiblePicture } from "./quality/visible-rung.js";
|
|
28
|
-
import {
|
|
29
|
-
import { AUDIO_TRANSCODE_KBPS } from "./args.js";
|
|
28
|
+
import { SOUNDTRACK_MODE_CAUSE, chooseSoundtrackMode, linkAnswerFigures as linkFiguresOf, soundtrackLoadOf } from "./quality/link-budget.js";
|
|
30
29
|
|
|
31
30
|
/**
|
|
32
31
|
* @typedef {Object} SegmentOutputFiles
|
|
@@ -166,7 +165,7 @@ export class OutputOpening {
|
|
|
166
165
|
// own. Part of the output's identity: another limit is another output.
|
|
167
166
|
capKbps = null,
|
|
168
167
|
// The soundtrack THIS viewer receives with the picture — from this output
|
|
169
|
-
// or beside it — as `{ transcode,
|
|
168
|
+
// or beside it — as `{ transcode, entry }`. Left out, the one this
|
|
170
169
|
// request names is theirs. A step opened on a viewer's behalf passes the
|
|
171
170
|
// track they chose, which need not be the one the picture was opened with.
|
|
172
171
|
viewerAudio = undefined,
|
|
@@ -215,9 +214,6 @@ export class OutputOpening {
|
|
|
215
214
|
: 0;
|
|
216
215
|
const normalizedAudioTrack =
|
|
217
216
|
Number.isInteger(audioTrackIndex) && audioTrackIndex > 0 ? audioTrackIndex : 0;
|
|
218
|
-
// What the requester wants of the sound, handed back so the viewer who
|
|
219
|
-
// asked can be told apart from another viewer of the same output.
|
|
220
|
-
const audio = { trackIndex: normalizedAudioTrack, transcode: transcodeAudio === true };
|
|
221
217
|
// Which FILE the chosen soundtrack lives in, and which track it is inside
|
|
222
218
|
// that file. A release often ships its dub as a file of its own beside the
|
|
223
219
|
// picture, and the number that travels between the browser, this route and
|
|
@@ -225,6 +221,30 @@ export class OutputOpening {
|
|
|
225
221
|
// is the one place that resolves it, so nothing downstream carries two
|
|
226
222
|
// vocabularies.
|
|
227
223
|
const audioSource = this.#host.renditions.resolveAudioSource(sourceKey, fileIndex, normalizedAudioTrack);
|
|
224
|
+
// HOW the soundtrack is produced, decided here, before anything is named by
|
|
225
|
+
// it, by the rule every choice of a track uses (`chooseSoundtrackMode`):
|
|
226
|
+
// the request says whether the browser plays the track as it is, and the
|
|
227
|
+
// inventory whether anything states what a copy of it weighs. A track with
|
|
228
|
+
// no figure is re-encoded even for a browser that would play it, because a
|
|
229
|
+
// copy of it is a load no link can be asked about. Whatever is decided is
|
|
230
|
+
// what the output IS — its key, its ffmpeg arguments, the link's question —
|
|
231
|
+
// and what the viewer is recorded as receiving.
|
|
232
|
+
const soundtrackMode = chooseSoundtrackMode({
|
|
233
|
+
entry: audioSource.entry,
|
|
234
|
+
browserPlays: transcodeAudio !== true,
|
|
235
|
+
// Nothing reaches this line on a proxy whose transcoding is off: the
|
|
236
|
+
// opening refuses every request above, so here re-encoding is allowed.
|
|
237
|
+
transcodeAllowed: true
|
|
238
|
+
});
|
|
239
|
+
const audioTranscoded = soundtrackMode.transcode === true;
|
|
240
|
+
if (soundtrackMode.cause === SOUNDTRACK_MODE_CAUSE.NO_FIGURE) {
|
|
241
|
+
this.#host.logger.info(
|
|
242
|
+
`transcode "${fileName || `file ${fileIndex}`}": audio track ${normalizedAudioTrack} — ${soundtrackMode.cause}; it is sent as AAC`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
// What the requester receives of the sound, handed back so the viewer who
|
|
246
|
+
// asked can be told apart from another viewer of the same output.
|
|
247
|
+
const audio = { trackIndex: normalizedAudioTrack, transcode: audioTranscoded };
|
|
228
248
|
// The file itself, held once for every session of it. Its name, its key and
|
|
229
249
|
// the facts a probe of it returned used to be copied onto each session, so
|
|
230
250
|
// two viewers of one film held two copies of numbers that cannot differ —
|
|
@@ -579,12 +599,9 @@ export class OutputOpening {
|
|
|
579
599
|
tonemap: applyTonemap,
|
|
580
600
|
capKbps,
|
|
581
601
|
limitsFor: (frame) => this.#host.limitsFor(frame),
|
|
582
|
-
audioLoad:
|
|
583
|
-
viewerAudio
|
|
584
|
-
|
|
585
|
-
: { transcode: transcodeAudio === true, bitrateKbps: audioSource.bitrateKbps ?? null },
|
|
586
|
-
AUDIO_TRANSCODE_KBPS
|
|
587
|
-
),
|
|
602
|
+
audioLoad: viewerAudio !== undefined
|
|
603
|
+
? soundtrackLoadOf(viewerAudio?.entry ?? null, viewerAudio ? viewerAudio.transcode : null)
|
|
604
|
+
: soundtrackLoadOf(audioSource.entry, audioTranscoded),
|
|
588
605
|
specWith: (encode) => new OutputSpec({
|
|
589
606
|
sourceKey,
|
|
590
607
|
segmentFormatId: segmentFormat.id,
|
|
@@ -593,7 +610,7 @@ export class OutputOpening {
|
|
|
593
610
|
grid: new CutGrid({ kind: useKeyframeGrid ? "keyframe" : "uniform", fileIndex }),
|
|
594
611
|
video: carriesVideo ? new VideoOutput({ fileIndex, encode }) : null,
|
|
595
612
|
audio: carriesAudio
|
|
596
|
-
? new AudioOutput({ fileIndex: audioSource.fileIndex, trackIndex: audioSource.sourceTrackIndex, transcode:
|
|
613
|
+
? new AudioOutput({ fileIndex: audioSource.fileIndex, trackIndex: audioSource.sourceTrackIndex, transcode: audioTranscoded })
|
|
597
614
|
: null
|
|
598
615
|
}),
|
|
599
616
|
serving: {
|
|
@@ -617,7 +634,15 @@ export class OutputOpening {
|
|
|
617
634
|
error.code = decided.unavailable.bound === "machine" && typeof claim === "function"
|
|
618
635
|
? OUTPUT_NO_CAPACITY
|
|
619
636
|
: OUTPUT_UNAVAILABLE;
|
|
620
|
-
error.details = {
|
|
637
|
+
error.details = {
|
|
638
|
+
reason: decided.unavailable.reason,
|
|
639
|
+
// How many soundtracks the viewer could choose instead, so the page can
|
|
640
|
+
// offer another one only where there is one to offer.
|
|
641
|
+
figures: decided.unavailable.figures
|
|
642
|
+
? { ...decided.unavailable.figures, soundtracks: this.#host.renditions.offeredSoundtrackCount(sourceKey, fileIndex) }
|
|
643
|
+
: null,
|
|
644
|
+
wantedKey: decided.wantedKey
|
|
645
|
+
};
|
|
621
646
|
throw error;
|
|
622
647
|
}
|
|
623
648
|
if (decided.answer) {
|
|
@@ -723,7 +748,7 @@ export class OutputOpening {
|
|
|
723
748
|
// side saying what it meant is why that took three attempts to place.
|
|
724
749
|
`start=${Math.round(normalizedStartPosition)}s ` +
|
|
725
750
|
`video=${transcodeVideo ? `${this.#host.videoEncoder.name}${output.softwarePreset ? `/${output.softwarePreset}` : ""}` : "copy"} ` +
|
|
726
|
-
`audio=${
|
|
751
|
+
`audio=${audioTranscoded ? "aac" : "copy"} ` +
|
|
727
752
|
// Branch tag for log correlation: A = video re-encode (fixed GOP, grid
|
|
728
753
|
// aligned, ts-offset); B = video copy (cut at source keyframes, copyts).
|
|
729
754
|
`branch=${transcodeVideo ? "A(reencode,fixed-gop)" : "B(copy,copyts)"} ` +
|
|
@@ -12,8 +12,8 @@ import { isOutputName, OUTPUT_UNAVAILABLE } from "./output/index.js";
|
|
|
12
12
|
import { masterPlaylistText, PLAYLIST_FILE_NAME } from "./output/playlists.js";
|
|
13
13
|
import { variantHeightsFor } from "./output/ladder.js";
|
|
14
14
|
import { isSameMaterial, outputSuits } from "./quality/serving-output.js";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
15
|
+
import { SOUNDTRACK_MODE_CAUSE, chooseSoundtrackMode, linkAnswerFigures, linkCouldCarry, loadOf, soundtrackLoadOf, videoLoadOfLimit, videoLoadOfSpec } from "./quality/link-budget.js";
|
|
16
|
+
import { maxrateKbpsFor } from "./args.js";
|
|
17
17
|
import { encoderInputs } from "./run-inputs.js";
|
|
18
18
|
/**
|
|
19
19
|
* How a base files the audio renditions it has made.
|
|
@@ -120,7 +120,7 @@ export class Renditions {
|
|
|
120
120
|
*/
|
|
121
121
|
#suitsViewer(output, base, consumerId, wanted = null) {
|
|
122
122
|
const linkMbps = this.#host.linkMbpsOf(base, consumerId);
|
|
123
|
-
const audioLoad =
|
|
123
|
+
const audioLoad = this.viewerAudioLoadOf(base, consumerId);
|
|
124
124
|
const encode = output.spec?.video?.encode ?? null;
|
|
125
125
|
return outputSuits({
|
|
126
126
|
spec: output.spec,
|
|
@@ -145,33 +145,114 @@ export class Renditions {
|
|
|
145
145
|
* @returns {import("./quality/link-budget.js").LoadPart | null}
|
|
146
146
|
*/
|
|
147
147
|
viewerAudioLoadOf(base, consumerId) {
|
|
148
|
-
|
|
148
|
+
const audio = this.#viewerAudioOf(base, consumerId);
|
|
149
|
+
return soundtrackLoadOf(audio.entry, audio.transcode);
|
|
149
150
|
}
|
|
150
151
|
|
|
151
152
|
/**
|
|
152
153
|
* The soundtrack THIS viewer receives with the picture, as the link sees it:
|
|
153
|
-
*
|
|
154
|
+
* the mode it is produced in, and the inventory entry that says what it
|
|
155
|
+
* weighs.
|
|
154
156
|
*
|
|
155
157
|
* Their own choice where they have made one, else the picture's own track.
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
+
* The mode is the one recorded when they chose it — what they are actually
|
|
159
|
+
* sent — and is NOT decided again here: a later reading that gives a track a
|
|
160
|
+
* figure changes the decision for the next choice, not the soundtrack
|
|
161
|
+
* already reaching them. Counted whether it travels inside the picture or as
|
|
162
|
+
* a stream of its own: it crosses the same link either way.
|
|
158
163
|
*
|
|
159
164
|
* @param {HlsSession} base
|
|
160
165
|
* @param {string} consumerId
|
|
161
|
-
* @returns {{ transcode: boolean,
|
|
166
|
+
* @returns {{ transcode: boolean, entry: object | null }}
|
|
162
167
|
*/
|
|
163
168
|
#viewerAudioOf(base, consumerId) {
|
|
164
169
|
const choice = this.#host.audioChoiceOf(base, consumerId) ??
|
|
165
170
|
{ trackIndex: this.#flatAudioTrackOf(base), transcode: base.spec.transcodesAudio };
|
|
171
|
+
return {
|
|
172
|
+
transcode: choice.transcode === true,
|
|
173
|
+
entry: this.#audioEntryOf(base, choice.trackIndex)
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The soundtrack this viewer is recorded as receiving with this picture, and
|
|
179
|
+
* whether it reaches them re-encoded.
|
|
180
|
+
*
|
|
181
|
+
* @param {HlsSession} base
|
|
182
|
+
* @param {string} consumerId
|
|
183
|
+
* @returns {{ trackIndex: number, transcode: boolean }}
|
|
184
|
+
*/
|
|
185
|
+
soundtrackOf(base, consumerId) {
|
|
186
|
+
const choice = this.#audioChoiceOf(base, consumerId);
|
|
187
|
+
return { trackIndex: choice.trackIndex, transcode: choice.transcode === true };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* How many soundtracks a viewer of this file can choose between: the
|
|
192
|
+
* inventory's tracks, less those the container marks unusable, which the
|
|
193
|
+
* page leaves out of its menu.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} sourceKey
|
|
196
|
+
* @param {number} fileIndex - The PICTURE's file.
|
|
197
|
+
* @returns {number}
|
|
198
|
+
*/
|
|
199
|
+
offeredSoundtrackCount(sourceKey, fileIndex) {
|
|
200
|
+
const inventory = this.#host.getCachedAudioTracks?.({ sourceKey, fileIndex }) ?? [];
|
|
201
|
+
return Array.isArray(inventory) ? inventory.filter((entry) => entry?.isEnabled !== false).length : 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* One soundtrack's entry in the inventory of this picture's file, or null.
|
|
206
|
+
*
|
|
207
|
+
* @param {HlsSession} base
|
|
208
|
+
* @param {number} trackIndex
|
|
209
|
+
* @returns {object | null}
|
|
210
|
+
*/
|
|
211
|
+
#audioEntryOf(base, trackIndex) {
|
|
166
212
|
const inventory = this.#host.getCachedAudioTracks?.({
|
|
167
213
|
sourceKey: base.file.sourceKey,
|
|
168
214
|
fileIndex: base.file.fileIndex
|
|
169
215
|
}) ?? [];
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
216
|
+
return Array.isArray(inventory) ? (inventory.find((one) => one?.index === trackIndex) ?? null) : null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* How a soundtrack is produced for this viewer, when they move to it.
|
|
221
|
+
*
|
|
222
|
+
* Decided by the rule every choice of a track uses (`chooseSoundtrackMode`).
|
|
223
|
+
* What their browser can play is what the page states for THAT track when it
|
|
224
|
+
* prepares the switch; a page that states nothing is taken to need what it
|
|
225
|
+
* needed for the track it is on — what every switch assumed before a page
|
|
226
|
+
* could say. A track being prepared for them, or already theirs, keeps the
|
|
227
|
+
* mode it was given, so the files of one switch all come from one output.
|
|
228
|
+
*
|
|
229
|
+
* @param {HlsSession} base
|
|
230
|
+
* @param {string} consumerId
|
|
231
|
+
* @param {number} trackIndex
|
|
232
|
+
* @param {boolean | null} [browserPlays] - What the page stated, or null.
|
|
233
|
+
* @returns {boolean | null} Whether to re-encode it; null when it cannot be
|
|
234
|
+
* served.
|
|
235
|
+
*/
|
|
236
|
+
#audioModeFor(base, consumerId, trackIndex, browserPlays = null) {
|
|
237
|
+
const choice = this.#audioChoiceOf(base, consumerId);
|
|
238
|
+
if (browserPlays === null) {
|
|
239
|
+
const warming = this.#host.outputs.get(this.#host.audioBeingWarmedOf(base, consumerId) ?? "");
|
|
240
|
+
if (warming && this.#flatAudioTrackOf(warming) === trackIndex) {
|
|
241
|
+
return warming.spec.transcodesAudio;
|
|
242
|
+
}
|
|
243
|
+
if (choice.trackIndex === trackIndex) {
|
|
244
|
+
return choice.transcode;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const decided = chooseSoundtrackMode({
|
|
248
|
+
entry: this.#audioEntryOf(base, trackIndex),
|
|
249
|
+
browserPlays: browserPlays ?? !choice.transcode,
|
|
250
|
+
transcodeAllowed: this.#host.transcodeEnabled !== false
|
|
251
|
+
});
|
|
252
|
+
if (decided.cause === SOUNDTRACK_MODE_CAUSE.NO_FIGURE) {
|
|
253
|
+
this.#host.logger.info(`transcode ${base.id} audio track ${trackIndex}: ${decided.cause}; it is sent as AAC`);
|
|
254
|
+
}
|
|
255
|
+
return decided.transcode;
|
|
175
256
|
}
|
|
176
257
|
|
|
177
258
|
async resolveVariantSession(baseSessionId, height, wantedIndex = -1, consumerId = "") {
|
|
@@ -1206,9 +1287,12 @@ export class Renditions {
|
|
|
1206
1287
|
* @param {string} baseSessionId
|
|
1207
1288
|
* @param {number} trackIndex
|
|
1208
1289
|
* @param {number} positionSeconds
|
|
1290
|
+
* @param {string} consumerId
|
|
1291
|
+
* @param {boolean | null} [browserPlays] - Whether the page states its browser
|
|
1292
|
+
* plays this track as it is; null when it does not say.
|
|
1209
1293
|
* @returns {Promise<{ sessionId: string, fileName: string } | null>}
|
|
1210
1294
|
*/
|
|
1211
|
-
async prepareAudioTrack(baseSessionId, trackIndex, positionSeconds, consumerId) {
|
|
1295
|
+
async prepareAudioTrack(baseSessionId, trackIndex, positionSeconds, consumerId, browserPlays = null) {
|
|
1212
1296
|
const base = this.#host.outputs.get(baseSessionId);
|
|
1213
1297
|
// A track is prepared FOR somebody; a request naming nobody prepares nothing.
|
|
1214
1298
|
if (!consumerId || !base || !this.servesAudioSeparately(base)) {
|
|
@@ -1217,7 +1301,7 @@ export class Renditions {
|
|
|
1217
1301
|
if (!this.#audioRenditionsOf(base).some((track) => track.trackIndex === trackIndex)) {
|
|
1218
1302
|
return null;
|
|
1219
1303
|
}
|
|
1220
|
-
const rendition = await this.#resolveAudioRenditionSession(base, trackIndex, consumerId);
|
|
1304
|
+
const rendition = await this.#resolveAudioRenditionSession(base, trackIndex, consumerId, browserPlays);
|
|
1221
1305
|
if (!rendition) {
|
|
1222
1306
|
return null;
|
|
1223
1307
|
}
|
|
@@ -1657,7 +1741,7 @@ export class Renditions {
|
|
|
1657
1741
|
this.#host.placeViewerOn(rendition, consumerId, this.#host.viewerPositionOf(base.id, consumerId));
|
|
1658
1742
|
}
|
|
1659
1743
|
if (isSegment && rendition) {
|
|
1660
|
-
this.#noteAudioTrackActive(base,
|
|
1744
|
+
this.#noteAudioTrackActive(base, rendition, consumerId);
|
|
1661
1745
|
}
|
|
1662
1746
|
return { sessionId: rendition?.id ?? null };
|
|
1663
1747
|
}
|
|
@@ -1685,20 +1769,26 @@ export class Renditions {
|
|
|
1685
1769
|
* and stopping "the others" per request would have them switch each other's
|
|
1686
1770
|
* soundtrack off in turn, once per segment, for the whole film.
|
|
1687
1771
|
*
|
|
1772
|
+
* What is recorded is the soundtrack they are SENT — its track and the mode
|
|
1773
|
+
* the output that serves it is produced in — so every later reading of
|
|
1774
|
+
* their load counts the output that actually reaches them.
|
|
1775
|
+
*
|
|
1688
1776
|
* @param {HlsSession} base
|
|
1689
|
-
* @param {
|
|
1777
|
+
* @param {HlsSession} rendition - The output the segment came from.
|
|
1690
1778
|
* @param {string} consumerId - Who is listening. A request that names
|
|
1691
1779
|
* nobody changes nobody's track.
|
|
1692
1780
|
*/
|
|
1693
|
-
#noteAudioTrackActive(base,
|
|
1781
|
+
#noteAudioTrackActive(base, rendition, consumerId) {
|
|
1694
1782
|
if (!consumerId) {
|
|
1695
1783
|
return;
|
|
1696
1784
|
}
|
|
1785
|
+
const trackIndex = this.#flatAudioTrackOf(rendition);
|
|
1786
|
+
const transcode = rendition.spec.transcodesAudio === true;
|
|
1697
1787
|
const previous = this.#audioChoiceOf(base, consumerId);
|
|
1698
|
-
if (previous.trackIndex === trackIndex) {
|
|
1788
|
+
if (previous.trackIndex === trackIndex && previous.transcode === transcode) {
|
|
1699
1789
|
return;
|
|
1700
1790
|
}
|
|
1701
|
-
this.#host.chooseAudioTrack(base, consumerId, {
|
|
1791
|
+
this.#host.chooseAudioTrack(base, consumerId, { trackIndex, transcode });
|
|
1702
1792
|
// Every soundtrack nobody present is listening to any more stops being
|
|
1703
1793
|
// waited on.
|
|
1704
1794
|
const wanted = this.#liveAudioRenditionKeys(base);
|
|
@@ -1800,17 +1890,23 @@ export class Renditions {
|
|
|
1800
1890
|
*
|
|
1801
1891
|
* Filed under the track AND how it has to be produced, because those are two
|
|
1802
1892
|
* different encodes: a browser that can decode this track as it stands is
|
|
1803
|
-
* served a copy, and one that cannot is served AAC
|
|
1804
|
-
*
|
|
1805
|
-
*
|
|
1893
|
+
* served a copy, and one that cannot is served AAC — and so is a track with
|
|
1894
|
+
* no stated rate, whose copy the viewer's link could not be asked about. The
|
|
1895
|
+
* base cannot answer for either of them now that it is shared — its own
|
|
1896
|
+
* `transcodeAudio` is whatever the first viewer's browser needed.
|
|
1806
1897
|
*
|
|
1807
1898
|
* @param {HlsSession} base
|
|
1808
1899
|
* @param {number} trackIndex
|
|
1809
1900
|
* @param {string} consumerId - Who is asking.
|
|
1810
|
-
* @
|
|
1901
|
+
* @param {boolean | null} [browserPlays] - What the page stated, or null.
|
|
1902
|
+
* @returns {Promise<HlsSession | null>} Null as well when the track cannot be
|
|
1903
|
+
* served to this viewer at all.
|
|
1811
1904
|
*/
|
|
1812
|
-
async #resolveAudioRenditionSession(base, trackIndex, consumerId = "") {
|
|
1813
|
-
const transcodeAudio = this.#
|
|
1905
|
+
async #resolveAudioRenditionSession(base, trackIndex, consumerId = "", browserPlays = null) {
|
|
1906
|
+
const transcodeAudio = this.#audioModeFor(base, consumerId, trackIndex, browserPlays);
|
|
1907
|
+
if (transcodeAudio === null) {
|
|
1908
|
+
return null;
|
|
1909
|
+
}
|
|
1814
1910
|
// Found by what it IS: a soundtrack of this file, this track, produced this
|
|
1815
1911
|
// way. That is what a map from a rendition key to a session id said, at the
|
|
1816
1912
|
// price of a link between two sessions' lifetimes — one that had to be
|
|
@@ -1945,7 +2041,7 @@ export class Renditions {
|
|
|
1945
2041
|
* @param {string} sourceKey
|
|
1946
2042
|
* @param {number} fileIndex - The PICTURE's file.
|
|
1947
2043
|
* @param {number} flatIndex
|
|
1948
|
-
* @returns {{ fileIndex: number, sourceTrackIndex: number, isSidecar: boolean, name: string }}
|
|
2044
|
+
* @returns {{ fileIndex: number, sourceTrackIndex: number, isSidecar: boolean, name: string, entry: object | null }}
|
|
1949
2045
|
*/
|
|
1950
2046
|
resolveAudioSource(sourceKey, fileIndex, flatIndex) {
|
|
1951
2047
|
const inventory = this.#host.getCachedAudioTracks?.({ sourceKey, fileIndex }) ?? [];
|
|
@@ -1953,16 +2049,18 @@ export class Renditions {
|
|
|
1953
2049
|
? inventory.find((candidate) => candidate?.index === flatIndex)
|
|
1954
2050
|
: null;
|
|
1955
2051
|
if (!entry || !Number.isInteger(entry.fileIndex) || !Number.isInteger(entry.sourceTrackIndex)) {
|
|
1956
|
-
return { fileIndex, sourceTrackIndex: flatIndex, isSidecar: false, name: "" };
|
|
2052
|
+
return { fileIndex, sourceTrackIndex: flatIndex, isSidecar: false, name: "", entry: null };
|
|
1957
2053
|
}
|
|
1958
2054
|
return {
|
|
1959
2055
|
fileIndex: entry.fileIndex,
|
|
1960
2056
|
sourceTrackIndex: entry.sourceTrackIndex,
|
|
1961
2057
|
isSidecar: entry.fileIndex !== fileIndex,
|
|
1962
2058
|
name: typeof entry.fileName === "string" ? entry.fileName : "",
|
|
1963
|
-
// The
|
|
1964
|
-
//
|
|
1965
|
-
|
|
2059
|
+
// The whole entry, which is what says what the track weighs on a link —
|
|
2060
|
+
// the bound its codec allows, the rate the file states — and decides how
|
|
2061
|
+
// it is produced. Handed on whole, so no conversion between here and the
|
|
2062
|
+
// link's question can drop a field of it.
|
|
2063
|
+
entry
|
|
1966
2064
|
};
|
|
1967
2065
|
}
|
|
1968
2066
|
|
package/services/encode/args.js
CHANGED
|
@@ -87,9 +87,11 @@ const NOMINAL_KBPS_BY_ROW = new Map([
|
|
|
87
87
|
// written down beside it.
|
|
88
88
|
const ROW_REFERENCE_SOURCE = Object.freeze({ width: 3840, height: 2160 });
|
|
89
89
|
/**
|
|
90
|
-
* The rate a re-encoded soundtrack is
|
|
91
|
-
*
|
|
92
|
-
*
|
|
90
|
+
* The rate a re-encoded soundtrack is asked for, in kbit/s: stereo AAC. A
|
|
91
|
+
* TARGET, not a bound — ffmpeg's AAC encoder lets single frames run above it
|
|
92
|
+
* from its bit reservoir. Stated once because two places need the same figure —
|
|
93
|
+
* the ffmpeg arguments that ask for it, and the viewer's link that has to carry
|
|
94
|
+
* it, which counts it as an estimate (`quality/link-budget.js`).
|
|
93
95
|
*/
|
|
94
96
|
export const AUDIO_TRANSCODE_KBPS = 128;
|
|
95
97
|
const CAP_MAXRATE_FACTOR = 1.3;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** Short startup measurements of packaged audio samples. */
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { AUDIO_TRANSCODE_KBPS } from "./args.js";
|
|
7
|
+
import { ENCODE_BENCHMARK_TIMEOUT_MS } from "./hwaccel.js";
|
|
8
|
+
|
|
9
|
+
const SAMPLE_DIRECTORY = fileURLToPath(new URL("../../assets/calibration/audio/", import.meta.url));
|
|
10
|
+
|
|
11
|
+
function measure(ffmpegBin, file, operation, signal) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const startedAt = performance.now();
|
|
14
|
+
let mediaSeconds = 0;
|
|
15
|
+
let text = "";
|
|
16
|
+
let diagnostics = "";
|
|
17
|
+
const child = spawn(ffmpegBin, ["-hide_banner", "-loglevel", "info", "-nostats", "-benchmark",
|
|
18
|
+
"-i", file, "-map", "0:a:0", "-vn", "-c:a", operation,
|
|
19
|
+
...(operation === "aac" ? ["-b:a", `${AUDIO_TRANSCODE_KBPS}k`, "-ac", "2"] : []),
|
|
20
|
+
"-f", "null", "-", "-progress", "pipe:1"],
|
|
21
|
+
{ stdio: ["ignore", "pipe", "pipe"], windowsHide: true,
|
|
22
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(ENCODE_BENCHMARK_TIMEOUT_MS)]) :
|
|
23
|
+
AbortSignal.timeout(ENCODE_BENCHMARK_TIMEOUT_MS) });
|
|
24
|
+
child.stdout.on("data", (data) => {
|
|
25
|
+
text += String(data);
|
|
26
|
+
const rows = text.split("\n");
|
|
27
|
+
text = rows.pop();
|
|
28
|
+
for (const row of rows) {
|
|
29
|
+
if (row.startsWith("out_time_us=")) {
|
|
30
|
+
const value = Number(row.slice("out_time_us=".length)) / 1e6;
|
|
31
|
+
if (Number.isFinite(value) && value > 0) mediaSeconds = value;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
child.stderr.on("data", (data) => { diagnostics += String(data); });
|
|
36
|
+
child.on("error", () => resolve(null));
|
|
37
|
+
child.on("close", (code) => {
|
|
38
|
+
const elapsed = (performance.now() - startedAt) / 1000;
|
|
39
|
+
const runtime = diagnostics.match(/bench:.*rtime=(\d+\.(\d+))s/);
|
|
40
|
+
// The reported decimal precision bounds timing uncertainty, including a
|
|
41
|
+
// runtime printed as zero. This is a clock-resolution bound, not a weight.
|
|
42
|
+
const processingSeconds = runtime ? Number(runtime[1]) + 10 ** -runtime[2].length : null;
|
|
43
|
+
resolve(code === 0 && mediaSeconds > 0 && processingSeconds > 0 ?
|
|
44
|
+
{ speed: mediaSeconds / processingSeconds, work: mediaSeconds, elapsed, processingSeconds,
|
|
45
|
+
startupSeconds: Math.max(0, elapsed - processingSeconds) } : null);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function benchmarkAudio({ ffmpegBin, logger, signal, sampleDirectory = SAMPLE_DIRECTORY }) {
|
|
51
|
+
const samples = JSON.parse(await readFile(path.join(sampleDirectory, "manifest.json"), "utf8"));
|
|
52
|
+
const readings = [];
|
|
53
|
+
const startedAt = performance.now();
|
|
54
|
+
for (const sample of samples) {
|
|
55
|
+
for (const operation of ["copy", "aac"]) {
|
|
56
|
+
if (signal?.aborted) return readings;
|
|
57
|
+
const reading = await measure(ffmpegBin, path.join(sampleDirectory, sample.file), operation, signal);
|
|
58
|
+
if (reading) readings.push({ ...sample, operation, ...reading });
|
|
59
|
+
else logger?.warn(`audio calibration ${sample.codec}->${operation} could not be measured`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
logger?.info(`audio calibration: ${readings.length} short packaged-sample measurements in ` +
|
|
63
|
+
`${((performance.now() - startedAt) / 1000).toFixed(2)}s`);
|
|
64
|
+
return readings;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export { audioReadingFor } from "./audio-work-rate.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Initial per-codec work model, replaced by the track's actual speed. */
|
|
2
|
+
export function audioReadingFor(readings, { codec, transcode, channels, samplingFrequency, bitrateKbps }) {
|
|
3
|
+
const operation = transcode ? "aac" : "copy";
|
|
4
|
+
const normalized = String(codec ?? "").replace(/^pcm_.*/, "pcm");
|
|
5
|
+
const reference = readings?.find((reading) => reading.codec === normalized && reading.operation === operation);
|
|
6
|
+
if (!reference) return null;
|
|
7
|
+
const referenceBytesPerSecond = reference.bytes / reference.durationSeconds;
|
|
8
|
+
// Copy work is encoded bytes; processing work is audio samples. Unstated
|
|
9
|
+
// dimensions retain the reference measurement, rather than invented values.
|
|
10
|
+
const workRatio = !transcode && bitrateKbps > 0 ? (bitrateKbps * 1000 / 8) / referenceBytesPerSecond :
|
|
11
|
+
transcode && channels > 0 && samplingFrequency > 0 ?
|
|
12
|
+
(channels * samplingFrequency) / (reference.channels * reference.samplingFrequency) : 1;
|
|
13
|
+
return { ...reference, speed: reference.speed / workRatio,
|
|
14
|
+
basis: workRatio === 1 ? "measured-reference" : transcode ? "audio-samples" : "encoded-bytes" };
|
|
15
|
+
}
|
|
@@ -94,7 +94,7 @@ const ENCODE_BENCHMARK_MAX_PLAUSIBLE_SPEED = 1000;
|
|
|
94
94
|
* A preset that has not reported twice in this long is hung, not slow: reports
|
|
95
95
|
* arrive twice a second whatever the encoding speed.
|
|
96
96
|
*/
|
|
97
|
-
const ENCODE_BENCHMARK_TIMEOUT_MS = 10_000;
|
|
97
|
+
export const ENCODE_BENCHMARK_TIMEOUT_MS = 10_000;
|
|
98
98
|
/**
|
|
99
99
|
* How many times the calibration clip is joined to itself to measure copying.
|
|
100
100
|
*
|
|
@@ -65,6 +65,7 @@ import { throughputAt } from "../throughput.js";
|
|
|
65
65
|
import { computeOutputDimensions, TRANSCODE_FPS } from "../args.js";
|
|
66
66
|
import { logger } from "../../../utils/logger.js";
|
|
67
67
|
import { qualityStateOf } from "./OutputQualityState.js";
|
|
68
|
+
import { audioReadingFor } from "../audio-work-rate.js";
|
|
68
69
|
|
|
69
70
|
export class EncodeCost {
|
|
70
71
|
/**
|
|
@@ -159,11 +160,20 @@ export class EncodeCost {
|
|
|
159
160
|
* about the machine — and because the offer's cache key needs it too, so a
|
|
160
161
|
* caller that has a session can ask without holding an instance.
|
|
161
162
|
*
|
|
162
|
-
*
|
|
163
|
+
* Named by everything that changes what producing it costs: the file the
|
|
164
|
+
* sound is read from, the track inside that file, and whether it is copied
|
|
165
|
+
* or re-encoded. One track may be copied for one viewer and re-encoded for
|
|
166
|
+
* another, and a copy's speed priced an encode it says nothing about; and a
|
|
167
|
+
* soundtrack shipped as its own file is track 0 of THAT file, which without
|
|
168
|
+
* the file in the name shared a price with the picture's own track 0.
|
|
169
|
+
*
|
|
170
|
+
* @param {{ file: { key: string }, spec?: { audioFileIndex?: number, audioSourceTrackIndex?: number, transcodesAudio?: boolean } }} session
|
|
163
171
|
* @returns {string}
|
|
164
172
|
*/
|
|
165
173
|
static audioKeyOf(session) {
|
|
166
|
-
|
|
174
|
+
const spec = session.spec ?? {};
|
|
175
|
+
return `${session.file.key}:a${spec.audioFileIndex ?? ""}:${spec.audioSourceTrackIndex ?? 0}:` +
|
|
176
|
+
`${spec.transcodesAudio === true ? "aac" : "copy"}`;
|
|
167
177
|
}
|
|
168
178
|
|
|
169
179
|
/**
|
|
@@ -369,7 +379,14 @@ export class EncodeCost {
|
|
|
369
379
|
}
|
|
370
380
|
if (session.spec.carries === "audio-only") {
|
|
371
381
|
const audio = this.#audioCost.get(EncodeCost.audioKeyOf(session));
|
|
372
|
-
|
|
382
|
+
const initial = audioReadingFor(this.#host().audioCalibration, {
|
|
383
|
+
...this.#host().audioDescriptionOf?.(session),
|
|
384
|
+
codec: this.#host().audioDescriptionOf?.(session)?.codec ?? session.file.media?.audioCodec,
|
|
385
|
+
transcode: session.spec.audio?.transcode === true
|
|
386
|
+
});
|
|
387
|
+
const initialSpeed = initial?.speed;
|
|
388
|
+
return { costSec: audio && audio.costSec > 0 ? audio.costSec :
|
|
389
|
+
initialSpeed > 0 ? 1 / initialSpeed : null, fileKey, fileCostSec };
|
|
373
390
|
}
|
|
374
391
|
if (!session.spec.transcodesVideo) {
|
|
375
392
|
const copy = this.#copyCost.get(session.file.key);
|
|
@@ -383,6 +400,15 @@ export class EncodeCost {
|
|
|
383
400
|
return { costSec: picture > 0 ? picture : null, fileKey, fileCostSec };
|
|
384
401
|
}
|
|
385
402
|
|
|
403
|
+
/** A prediction for this operation, using its measured cost and actual contention. */
|
|
404
|
+
projectedSpeedOf(session) {
|
|
405
|
+
const load = this.#loadOfSession(session);
|
|
406
|
+
const others = Math.max(0, this.#runningEncoders() - this.#runsFor(session).length);
|
|
407
|
+
const { penalty } = contentionPenalty(others, this.#host().contentionPenalties);
|
|
408
|
+
return load.costSec > 0 ? correctForAvailability(1 / (load.costSec * penalty),
|
|
409
|
+
this.#host().availability) : null;
|
|
410
|
+
}
|
|
411
|
+
|
|
386
412
|
/**
|
|
387
413
|
* What everything OTHER than this session is costing right now, or null when
|
|
388
414
|
* any of it is unpriced.
|