@storyteller-platform/align 0.1.47 → 0.1.48

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.
@@ -28,8 +28,11 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
28
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
29
  var ffmpeg_exports = {};
30
30
  __export(ffmpeg_exports, {
31
+ MP3_CBR_BITRATES: () => MP3_CBR_BITRATES,
31
32
  getTrackDuration: () => getTrackDuration,
32
33
  getTrackInfo: () => getTrackInfo,
34
+ isVbrMp3: () => isVbrMp3,
35
+ selectCbrBitrate: () => selectCbrBitrate,
33
36
  splitFile: () => splitFile,
34
37
  transcodeFile: () => transcodeFile
35
38
  });
@@ -74,6 +77,52 @@ async function getTrackDuration(path, logger) {
74
77
  const info = await getTrackInfo(path, logger);
75
78
  return info["duration"];
76
79
  }
80
+ const MP3_CBR_BITRATES = [
81
+ 64e3,
82
+ 8e4,
83
+ 96e3,
84
+ 112e3,
85
+ 128e3,
86
+ 16e4,
87
+ 192e3,
88
+ 224e3,
89
+ 256e3,
90
+ 32e4
91
+ ];
92
+ const VBR_PROBE_PACKET_COUNT = 50;
93
+ const MP3_CBR_MAX_DISTINCT_SIZES = 2;
94
+ const VBR_PROBE_MIN_SEEKABLE_SECONDS = 180;
95
+ async function probeAudioDuration(path) {
96
+ const stdout = await execCmd(
97
+ `ffprobe -i ${(0, import_shell.quotePath)(path)} -v error -show_entries format=duration -output_format json`
98
+ );
99
+ const { format } = JSON.parse(stdout);
100
+ const duration = Number(format?.duration);
101
+ return Number.isFinite(duration) && duration > 0 ? duration : null;
102
+ }
103
+ async function probePacketSizes(path, startSeconds) {
104
+ const interval = startSeconds > 0 ? `${startSeconds}%+#${VBR_PROBE_PACKET_COUNT}` : `%+#${VBR_PROBE_PACKET_COUNT}`;
105
+ const stdout = await execCmd(
106
+ `ffprobe -i ${(0, import_shell.quotePath)(path)} -v error -select_streams a:0 -read_intervals "${interval}" -show_entries packet=size -output_format json`
107
+ );
108
+ const { packets } = JSON.parse(stdout);
109
+ return (packets ?? []).map((packet) => Number(packet.size)).filter((size) => Number.isFinite(size) && size > 0);
110
+ }
111
+ async function isVbrMp3(path) {
112
+ if ((0, import_node_path.extname)(path).toLowerCase() !== ".mp3") return false;
113
+ const duration = await probeAudioDuration(path);
114
+ const startSeconds = duration && duration > VBR_PROBE_MIN_SEEKABLE_SECONDS ? Math.floor(duration / 3) : 0;
115
+ let sizes = await probePacketSizes(path, startSeconds);
116
+ if (sizes.length === 0 && startSeconds > 0) {
117
+ sizes = await probePacketSizes(path, 0);
118
+ }
119
+ if (sizes.length === 0) return false;
120
+ const distinctSizes = new Set(sizes).size;
121
+ return distinctSizes > MP3_CBR_MAX_DISTINCT_SIZES;
122
+ }
123
+ function selectCbrBitrate(averageBitrate) {
124
+ return MP3_CBR_BITRATES.find((tier) => tier >= averageBitrate) ?? MP3_CBR_BITRATES.at(-1) ?? MP3_CBR_BITRATES[0];
125
+ }
77
126
  function parseTrackInfo(format) {
78
127
  return {
79
128
  filename: format.filename,
@@ -136,15 +185,16 @@ async function constructExtractCoverArtCommand(source, destExtension) {
136
185
  ];
137
186
  return `${command} ${args.join(" ")} | `;
138
187
  }
139
- function commonFfmpegArguments(sourceExtension, destExtension, codec, bitrate) {
188
+ function commonFfmpegArguments(options) {
189
+ const { sourceExtension, destExtension, codec, bitrate } = options;
140
190
  const args = ["-vn"];
141
191
  if (codec) {
142
- args.push(
143
- "-c:a",
144
- codec,
145
- ...codec === "libopus" ? ["-b:a", bitrate && /^\d+[kK]$/i.test(bitrate) ? bitrate : "32K"] : [],
146
- ...codec === "libmp3lame" && bitrate ? ["-q:a", bitrate] : []
147
- );
192
+ args.push("-c:a", codec);
193
+ if (codec === "libopus") {
194
+ args.push("-b:a", bitrate && /^\d+[kK]$/i.test(bitrate) ? bitrate : "32K");
195
+ } else if (codec === "libmp3lame" && bitrate) {
196
+ args.push("-b:a", bitrate);
197
+ }
148
198
  } else if ((0, import_mime.areSameType)(sourceExtension, destExtension) || destExtension == ".mp4") {
149
199
  args.push("-c:a", "copy");
150
200
  }
@@ -168,12 +218,12 @@ async function splitFile(input, output, start, end, encoding, signal, logger) {
168
218
  end,
169
219
  "-i",
170
220
  (0, import_shell.quotePath)(input),
171
- ...commonFfmpegArguments(
172
- (0, import_node_path.extname)(input),
173
- (0, import_node_path.extname)(output),
174
- encoding?.codec ?? null,
175
- encoding?.bitrate ?? null
176
- ),
221
+ ...commonFfmpegArguments({
222
+ sourceExtension: (0, import_node_path.extname)(input),
223
+ destExtension: (0, import_node_path.extname)(output),
224
+ codec: encoding?.codec ?? null,
225
+ bitrate: encoding?.bitrate ?? null
226
+ }),
177
227
  (0, import_shell.quotePath)(output)
178
228
  ];
179
229
  const coverArtCommand = await constructExtractCoverArtCommand(
@@ -203,12 +253,12 @@ async function transcodeFile(input, output, encoding, signal, logger) {
203
253
  "-nostdin",
204
254
  "-i",
205
255
  (0, import_shell.quotePath)(input),
206
- ...commonFfmpegArguments(
207
- (0, import_node_path.extname)(input),
208
- (0, import_node_path.extname)(output),
209
- encoding?.codec ?? null,
210
- encoding?.bitrate ?? null
211
- ),
256
+ ...commonFfmpegArguments({
257
+ sourceExtension: (0, import_node_path.extname)(input),
258
+ destExtension: (0, import_node_path.extname)(output),
259
+ codec: encoding?.codec ?? null,
260
+ bitrate: encoding?.bitrate ?? null
261
+ }),
212
262
  (0, import_shell.quotePath)(output)
213
263
  ];
214
264
  const coverArtCommand = await constructExtractCoverArtCommand(
@@ -224,8 +274,11 @@ async function transcodeFile(input, output, encoding, signal, logger) {
224
274
  }
225
275
  // Annotate the CommonJS export names for ESM import in node:
226
276
  0 && (module.exports = {
277
+ MP3_CBR_BITRATES,
227
278
  getTrackDuration,
228
279
  getTrackInfo,
280
+ isVbrMp3,
281
+ selectCbrBitrate,
229
282
  splitFile,
230
283
  transcodeFile
231
284
  });
@@ -3,6 +3,22 @@ import { AudioEncoding } from '../process/AudioEncoding.cjs';
3
3
 
4
4
  declare const getTrackInfo: (path: string, logger?: Logger) => Promise<TrackInfo>;
5
5
  declare function getTrackDuration(path: string, logger?: Logger): Promise<number>;
6
+ /**
7
+ * CBR bitrates (bps) offered for MP3 output, roughly matching LAME -V9..-V0
8
+ */
9
+ declare const MP3_CBR_BITRATES: readonly [64000, 80000, 96000, 112000, 128000, 160000, 192000, 224000, 256000, 320000];
10
+ /**
11
+ * Detect whether an MP3 file uses a variable bitrate
12
+ * Does this by sampling the first few packets and checking if the sizes are different
13
+ * CBR MP3 files will have the same packet size for the entire file
14
+ *
15
+ * Can't really trust the reported bitrate to tell CBR from VBR
16
+ * LAME writes a Xing header carrying the *average* bitrate,
17
+ * which ffprobe surfaces as a normal per-stream `bit_rate`,
18
+ * so a VBR file looks identical to a CBR one by that measure.
19
+ */
20
+ declare function isVbrMp3(path: string): Promise<boolean>;
21
+ declare function selectCbrBitrate(averageBitrate: number): number;
6
22
  type TrackInfo = {
7
23
  filename: string;
8
24
  nbStreams: number;
@@ -30,4 +46,4 @@ type TrackInfo = {
30
46
  declare function splitFile(input: string, output: string, start: number, end: number, encoding?: AudioEncoding | null, signal?: AbortSignal | null, logger?: Logger | null): Promise<boolean>;
31
47
  declare function transcodeFile(input: string, output: string, encoding?: AudioEncoding | null, signal?: AbortSignal | null, logger?: Logger | null): Promise<true | undefined>;
32
48
 
33
- export { getTrackDuration, getTrackInfo, splitFile, transcodeFile };
49
+ export { MP3_CBR_BITRATES, getTrackDuration, getTrackInfo, isVbrMp3, selectCbrBitrate, splitFile, transcodeFile };
@@ -3,6 +3,22 @@ import { AudioEncoding } from '../process/AudioEncoding.js';
3
3
 
4
4
  declare const getTrackInfo: (path: string, logger?: Logger) => Promise<TrackInfo>;
5
5
  declare function getTrackDuration(path: string, logger?: Logger): Promise<number>;
6
+ /**
7
+ * CBR bitrates (bps) offered for MP3 output, roughly matching LAME -V9..-V0
8
+ */
9
+ declare const MP3_CBR_BITRATES: readonly [64000, 80000, 96000, 112000, 128000, 160000, 192000, 224000, 256000, 320000];
10
+ /**
11
+ * Detect whether an MP3 file uses a variable bitrate
12
+ * Does this by sampling the first few packets and checking if the sizes are different
13
+ * CBR MP3 files will have the same packet size for the entire file
14
+ *
15
+ * Can't really trust the reported bitrate to tell CBR from VBR
16
+ * LAME writes a Xing header carrying the *average* bitrate,
17
+ * which ffprobe surfaces as a normal per-stream `bit_rate`,
18
+ * so a VBR file looks identical to a CBR one by that measure.
19
+ */
20
+ declare function isVbrMp3(path: string): Promise<boolean>;
21
+ declare function selectCbrBitrate(averageBitrate: number): number;
6
22
  type TrackInfo = {
7
23
  filename: string;
8
24
  nbStreams: number;
@@ -30,4 +46,4 @@ type TrackInfo = {
30
46
  declare function splitFile(input: string, output: string, start: number, end: number, encoding?: AudioEncoding | null, signal?: AbortSignal | null, logger?: Logger | null): Promise<boolean>;
31
47
  declare function transcodeFile(input: string, output: string, encoding?: AudioEncoding | null, signal?: AbortSignal | null, logger?: Logger | null): Promise<true | undefined>;
32
48
 
33
- export { getTrackDuration, getTrackInfo, splitFile, transcodeFile };
49
+ export { MP3_CBR_BITRATES, getTrackDuration, getTrackInfo, isVbrMp3, selectCbrBitrate, splitFile, transcodeFile };
@@ -39,6 +39,52 @@ async function getTrackDuration(path, logger) {
39
39
  const info = await getTrackInfo(path, logger);
40
40
  return info["duration"];
41
41
  }
42
+ const MP3_CBR_BITRATES = [
43
+ 64e3,
44
+ 8e4,
45
+ 96e3,
46
+ 112e3,
47
+ 128e3,
48
+ 16e4,
49
+ 192e3,
50
+ 224e3,
51
+ 256e3,
52
+ 32e4
53
+ ];
54
+ const VBR_PROBE_PACKET_COUNT = 50;
55
+ const MP3_CBR_MAX_DISTINCT_SIZES = 2;
56
+ const VBR_PROBE_MIN_SEEKABLE_SECONDS = 180;
57
+ async function probeAudioDuration(path) {
58
+ const stdout = await execCmd(
59
+ `ffprobe -i ${quotePath(path)} -v error -show_entries format=duration -output_format json`
60
+ );
61
+ const { format } = JSON.parse(stdout);
62
+ const duration = Number(format?.duration);
63
+ return Number.isFinite(duration) && duration > 0 ? duration : null;
64
+ }
65
+ async function probePacketSizes(path, startSeconds) {
66
+ const interval = startSeconds > 0 ? `${startSeconds}%+#${VBR_PROBE_PACKET_COUNT}` : `%+#${VBR_PROBE_PACKET_COUNT}`;
67
+ const stdout = await execCmd(
68
+ `ffprobe -i ${quotePath(path)} -v error -select_streams a:0 -read_intervals "${interval}" -show_entries packet=size -output_format json`
69
+ );
70
+ const { packets } = JSON.parse(stdout);
71
+ return (packets ?? []).map((packet) => Number(packet.size)).filter((size) => Number.isFinite(size) && size > 0);
72
+ }
73
+ async function isVbrMp3(path) {
74
+ if (extname(path).toLowerCase() !== ".mp3") return false;
75
+ const duration = await probeAudioDuration(path);
76
+ const startSeconds = duration && duration > VBR_PROBE_MIN_SEEKABLE_SECONDS ? Math.floor(duration / 3) : 0;
77
+ let sizes = await probePacketSizes(path, startSeconds);
78
+ if (sizes.length === 0 && startSeconds > 0) {
79
+ sizes = await probePacketSizes(path, 0);
80
+ }
81
+ if (sizes.length === 0) return false;
82
+ const distinctSizes = new Set(sizes).size;
83
+ return distinctSizes > MP3_CBR_MAX_DISTINCT_SIZES;
84
+ }
85
+ function selectCbrBitrate(averageBitrate) {
86
+ return MP3_CBR_BITRATES.find((tier) => tier >= averageBitrate) ?? MP3_CBR_BITRATES.at(-1) ?? MP3_CBR_BITRATES[0];
87
+ }
42
88
  function parseTrackInfo(format) {
43
89
  return {
44
90
  filename: format.filename,
@@ -101,15 +147,16 @@ async function constructExtractCoverArtCommand(source, destExtension) {
101
147
  ];
102
148
  return `${command} ${args.join(" ")} | `;
103
149
  }
104
- function commonFfmpegArguments(sourceExtension, destExtension, codec, bitrate) {
150
+ function commonFfmpegArguments(options) {
151
+ const { sourceExtension, destExtension, codec, bitrate } = options;
105
152
  const args = ["-vn"];
106
153
  if (codec) {
107
- args.push(
108
- "-c:a",
109
- codec,
110
- ...codec === "libopus" ? ["-b:a", bitrate && /^\d+[kK]$/i.test(bitrate) ? bitrate : "32K"] : [],
111
- ...codec === "libmp3lame" && bitrate ? ["-q:a", bitrate] : []
112
- );
154
+ args.push("-c:a", codec);
155
+ if (codec === "libopus") {
156
+ args.push("-b:a", bitrate && /^\d+[kK]$/i.test(bitrate) ? bitrate : "32K");
157
+ } else if (codec === "libmp3lame" && bitrate) {
158
+ args.push("-b:a", bitrate);
159
+ }
113
160
  } else if (areSameType(sourceExtension, destExtension) || destExtension == ".mp4") {
114
161
  args.push("-c:a", "copy");
115
162
  }
@@ -133,12 +180,12 @@ async function splitFile(input, output, start, end, encoding, signal, logger) {
133
180
  end,
134
181
  "-i",
135
182
  quotePath(input),
136
- ...commonFfmpegArguments(
137
- extname(input),
138
- extname(output),
139
- encoding?.codec ?? null,
140
- encoding?.bitrate ?? null
141
- ),
183
+ ...commonFfmpegArguments({
184
+ sourceExtension: extname(input),
185
+ destExtension: extname(output),
186
+ codec: encoding?.codec ?? null,
187
+ bitrate: encoding?.bitrate ?? null
188
+ }),
142
189
  quotePath(output)
143
190
  ];
144
191
  const coverArtCommand = await constructExtractCoverArtCommand(
@@ -168,12 +215,12 @@ async function transcodeFile(input, output, encoding, signal, logger) {
168
215
  "-nostdin",
169
216
  "-i",
170
217
  quotePath(input),
171
- ...commonFfmpegArguments(
172
- extname(input),
173
- extname(output),
174
- encoding?.codec ?? null,
175
- encoding?.bitrate ?? null
176
- ),
218
+ ...commonFfmpegArguments({
219
+ sourceExtension: extname(input),
220
+ destExtension: extname(output),
221
+ codec: encoding?.codec ?? null,
222
+ bitrate: encoding?.bitrate ?? null
223
+ }),
177
224
  quotePath(output)
178
225
  ];
179
226
  const coverArtCommand = await constructExtractCoverArtCommand(
@@ -188,8 +235,11 @@ async function transcodeFile(input, output, encoding, signal, logger) {
188
235
  return true;
189
236
  }
190
237
  export {
238
+ MP3_CBR_BITRATES,
191
239
  getTrackDuration,
192
240
  getTrackInfo,
241
+ isVbrMp3,
242
+ selectCbrBitrate,
193
243
  splitFile,
194
244
  transcodeFile
195
245
  };
@@ -126,6 +126,27 @@ async function processAudiobook(input, output, options) {
126
126
  );
127
127
  return timing;
128
128
  }
129
+ async function resolveVbrEncoding(filepath, userEncoding, logger) {
130
+ if (userEncoding?.codec && userEncoding.codec !== "libmp3lame") {
131
+ return userEncoding;
132
+ }
133
+ const sourceIsMp3 = (0, import_node_path.extname)(filepath).toLowerCase() === ".mp3";
134
+ if (!userEncoding?.codec && !sourceIsMp3) {
135
+ return userEncoding;
136
+ }
137
+ if (!userEncoding?.codec && !await (0, import_ffmpeg.isVbrMp3)(filepath)) {
138
+ return userEncoding;
139
+ }
140
+ const trackInfo = await (0, import_ffmpeg.getTrackInfo)(filepath, logger ?? void 0);
141
+ const targetBitrate = (0, import_ffmpeg.selectCbrBitrate)(trackInfo.bitRate);
142
+ logger?.info(
143
+ `Forcing CBR MP3 for ${filepath} (avg ${trackInfo.bitRate}bps) at ${targetBitrate / 1e3}k`
144
+ );
145
+ return {
146
+ codec: "libmp3lame",
147
+ bitrate: `${targetBitrate / 1e3}k`
148
+ };
149
+ }
129
150
  async function processFile(input, output, prefix, options) {
130
151
  var _stack = [];
131
152
  try {
@@ -144,13 +165,26 @@ async function processFile(input, output, prefix, options) {
144
165
  options.signal,
145
166
  options.logger
146
167
  );
168
+ const vbrEncodings = /* @__PURE__ */ new Map();
169
+ const uniqueFilepaths = [...new Set(ranges.map((r) => r.filepath))];
170
+ await Promise.all(
171
+ uniqueFilepaths.map(async (filepath) => {
172
+ const result = await resolveVbrEncoding(
173
+ filepath,
174
+ options.encoding,
175
+ options.logger
176
+ );
177
+ vbrEncodings.set(filepath, result);
178
+ })
179
+ );
147
180
  await Promise.all(
148
181
  ranges.map(async (range, index) => {
149
182
  var _stack2 = [];
150
183
  try {
184
+ const effectiveEncoding = vbrEncodings.has(range.filepath) ? vbrEncodings.get(range.filepath) : options.encoding;
151
185
  const outputExtension = determineExtension(
152
186
  range.filepath,
153
- options.encoding?.codec
187
+ effectiveEncoding?.codec
154
188
  );
155
189
  const outputFilename = `${prefix}${(index + 1).toString().padStart(5, "0")}${outputExtension}`;
156
190
  const outputFilepath = (0, import_node_path.join)(output, outputFilename);
@@ -168,7 +202,7 @@ async function processFile(input, output, prefix, options) {
168
202
  outputFilepath,
169
203
  range.start,
170
204
  range.end,
171
- options.encoding,
205
+ effectiveEncoding,
172
206
  options.signal,
173
207
  options.logger
174
208
  );
@@ -19,7 +19,12 @@ import {
19
19
  createAggregator,
20
20
  createTiming
21
21
  } from "@storyteller-platform/ghost-story";
22
- import { splitFile } from "../common/ffmpeg.js";
22
+ import {
23
+ getTrackInfo,
24
+ isVbrMp3,
25
+ selectCbrBitrate,
26
+ splitFile
27
+ } from "../common/ffmpeg.js";
23
28
  import { getSafeChapterRanges } from "./ranges.js";
24
29
  async function processAudiobook(input, output, options) {
25
30
  const timing = createAggregator();
@@ -73,6 +78,27 @@ async function processAudiobook(input, output, options) {
73
78
  );
74
79
  return timing;
75
80
  }
81
+ async function resolveVbrEncoding(filepath, userEncoding, logger) {
82
+ if (userEncoding?.codec && userEncoding.codec !== "libmp3lame") {
83
+ return userEncoding;
84
+ }
85
+ const sourceIsMp3 = extname(filepath).toLowerCase() === ".mp3";
86
+ if (!userEncoding?.codec && !sourceIsMp3) {
87
+ return userEncoding;
88
+ }
89
+ if (!userEncoding?.codec && !await isVbrMp3(filepath)) {
90
+ return userEncoding;
91
+ }
92
+ const trackInfo = await getTrackInfo(filepath, logger ?? void 0);
93
+ const targetBitrate = selectCbrBitrate(trackInfo.bitRate);
94
+ logger?.info(
95
+ `Forcing CBR MP3 for ${filepath} (avg ${trackInfo.bitRate}bps) at ${targetBitrate / 1e3}k`
96
+ );
97
+ return {
98
+ codec: "libmp3lame",
99
+ bitrate: `${targetBitrate / 1e3}k`
100
+ };
101
+ }
76
102
  async function processFile(input, output, prefix, options) {
77
103
  var _stack = [];
78
104
  try {
@@ -91,13 +117,26 @@ async function processFile(input, output, prefix, options) {
91
117
  options.signal,
92
118
  options.logger
93
119
  );
120
+ const vbrEncodings = /* @__PURE__ */ new Map();
121
+ const uniqueFilepaths = [...new Set(ranges.map((r) => r.filepath))];
122
+ await Promise.all(
123
+ uniqueFilepaths.map(async (filepath) => {
124
+ const result = await resolveVbrEncoding(
125
+ filepath,
126
+ options.encoding,
127
+ options.logger
128
+ );
129
+ vbrEncodings.set(filepath, result);
130
+ })
131
+ );
94
132
  await Promise.all(
95
133
  ranges.map(async (range, index) => {
96
134
  var _stack2 = [];
97
135
  try {
136
+ const effectiveEncoding = vbrEncodings.has(range.filepath) ? vbrEncodings.get(range.filepath) : options.encoding;
98
137
  const outputExtension = determineExtension(
99
138
  range.filepath,
100
- options.encoding?.codec
139
+ effectiveEncoding?.codec
101
140
  );
102
141
  const outputFilename = `${prefix}${(index + 1).toString().padStart(5, "0")}${outputExtension}`;
103
142
  const outputFilepath = join(output, outputFilename);
@@ -115,7 +154,7 @@ async function processFile(input, output, prefix, options) {
115
154
  outputFilepath,
116
155
  range.start,
117
156
  range.end,
118
- options.encoding,
157
+ effectiveEncoding,
119
158
  options.signal,
120
159
  options.logger
121
160
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@storyteller-platform/align",
3
- "version": "0.1.47",
3
+ "version": "0.1.48",
4
4
  "description": "A library and CLI for automatically aligning audiobooks and EPUBs to produce Media Overlays",
5
5
  "author": "Shane Friedman",
6
6
  "license": "MIT",
@@ -71,7 +71,7 @@
71
71
  "@optique/run": "^0.10.7",
72
72
  "@readium/shared": "^2.2.0",
73
73
  "@storyteller-platform/audiobook": "^0.4.1",
74
- "@storyteller-platform/epub": "^0.6.1",
74
+ "@storyteller-platform/epub": "^0.6.2",
75
75
  "@storyteller-platform/ghost-story": "^0.1.11",
76
76
  "@storyteller-platform/transliteration": "^3.1.2",
77
77
  "chalk": "^5.4.1",