@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
@@ -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 { audioLoadOf, linkAnswerFigures as linkFiguresOf } from "./quality/link-budget.js";
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, bitrateKbps }`. Left out, the one this
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: audioLoadOf(
583
- viewerAudio !== undefined
584
- ? viewerAudio
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: transcodeAudio === true })
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 = { reason: decided.unavailable.reason, figures: decided.unavailable.figures, wantedKey: decided.wantedKey };
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=${transcodeAudio ? "aac" : "copy"} ` +
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 { audioLoadOf, linkAnswerFigures, linkCouldCarry, loadOf, videoLoadOfLimit, videoLoadOfSpec } from "./quality/link-budget.js";
16
- import { AUDIO_TRANSCODE_KBPS, maxrateKbpsFor } from "./args.js";
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 = audioLoadOf(this.#viewerAudioOf(base, consumerId), AUDIO_TRANSCODE_KBPS);
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
- return audioLoadOf(this.#viewerAudioOf(base, consumerId), AUDIO_TRANSCODE_KBPS);
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
- * whether it is re-encoded, and the rate the file states for it.
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
- * Counted whether it travels inside the picture or as a stream of its own:
157
- * it crosses the same link either way.
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, bitrateKbps: number | null }}
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
- const entry = Array.isArray(inventory) ? inventory.find((one) => one?.index === choice.trackIndex) : null;
171
- return {
172
- transcode: choice.transcode === true,
173
- bitrateKbps: Number.isFinite(entry?.bitrateKbps) ? entry.bitrateKbps : null
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, trackIndex, consumerId);
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 {number} trackIndex
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, trackIndex, consumerId) {
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, { ...previous, trackIndex });
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. The base cannot answer
1804
- * for either of them now that it is shared — its own `transcodeAudio` is
1805
- * whatever the first viewer's browser needed.
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
- * @returns {Promise<HlsSession | null>}
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.#audioChoiceOf(base, consumerId).transcode;
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 rate the file states for this track, or null; what a viewer's link
1964
- // is asked to carry for it when it is copied.
1965
- bitrateKbps: Number.isFinite(entry.bitrateKbps) ? entry.bitrateKbps : null
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
 
@@ -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 produced at, in kbit/s: stereo AAC,
91
- * constant. Stated once because two places need the same figure — the ffmpeg
92
- * arguments that produce it, and the viewer's link that has to carry it.
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
- * @param {{ file: { key: string }, audioTrackIndex?: number }} session
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
- return `${session.file.key}:${session.spec?.audioSourceTrackIndex ?? 0}`;
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
- return { costSec: audio && audio.costSec > 0 ? audio.costSec : null, fileKey, fileCostSec };
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.