ffmpeg-normalize 1.33.4__py3-none-any.whl → 1.35.0__py3-none-any.whl

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.
@@ -179,6 +179,24 @@ def create_parser() -> argparse.ArgumentParser:
179
179
  """
180
180
  ),
181
181
  )
182
+ group_normalization.add_argument(
183
+ "--batch",
184
+ action="store_true",
185
+ help=textwrap.dedent(
186
+ """\
187
+ Preserve relative loudness between files (album mode).
188
+
189
+ When operating on a group of unrelated files, you usually want all of them at the same
190
+ level. However, a group of music files all from the same album is generally meant to be
191
+ listened to at the relative volumes they were recorded at. In batch mode, all the specified
192
+ files are considered to be part of a single album and their relative volumes are preserved.
193
+ This is done by averaging the loudness of all the files, computing a single adjustment from
194
+ that, and applying a relative adjustment to all the files.
195
+
196
+ Batch mode works with all normalization types (EBU, RMS, peak).
197
+ """
198
+ ),
199
+ )
182
200
 
183
201
  # group_normalization.add_argument(
184
202
  # '--threshold',
@@ -315,6 +333,44 @@ def create_parser() -> argparse.ArgumentParser:
315
333
  ),
316
334
  )
317
335
 
336
+ group_stream_selection = parser.add_argument_group("Audio Stream Selection")
337
+ group_stream_selection.add_argument(
338
+ "-as",
339
+ "--audio-streams",
340
+ type=str,
341
+ help=textwrap.dedent(
342
+ """\
343
+ Select specific audio streams to normalize by stream index (comma-separated).
344
+ Example: --audio-streams 0,2 will normalize only streams 0 and 2.
345
+
346
+ By default, all audio streams are normalized.
347
+ """
348
+ ),
349
+ )
350
+ group_stream_selection.add_argument(
351
+ "--audio-default-only",
352
+ action="store_true",
353
+ help=textwrap.dedent(
354
+ """\
355
+ Only normalize audio streams with the 'default' disposition flag.
356
+ This is useful for files with multiple audio tracks where only the main track
357
+ should be normalized (e.g., keeping commentary tracks unchanged).
358
+ """
359
+ ),
360
+ )
361
+ group_stream_selection.add_argument(
362
+ "--keep-other-audio",
363
+ action="store_true",
364
+ help=textwrap.dedent(
365
+ """\
366
+ Keep non-selected audio streams in the output file (copy without normalization).
367
+ Only applies when --audio-streams or --audio-default-only is used.
368
+
369
+ By default, only selected streams are included in the output.
370
+ """
371
+ ),
372
+ )
373
+
318
374
  group_acodec = parser.add_argument_group("Audio Encoding")
319
375
  group_acodec.add_argument(
320
376
  "-c:a",
@@ -553,6 +609,18 @@ def main() -> None:
553
609
  extra_input_options = _split_options(cli_args.extra_input_options)
554
610
  extra_output_options = _split_options(cli_args.extra_output_options)
555
611
 
612
+ # parse audio streams selection
613
+ audio_streams = None
614
+ if cli_args.audio_streams:
615
+ try:
616
+ audio_streams = [int(s.strip()) for s in cli_args.audio_streams.split(",")]
617
+ except ValueError:
618
+ error("Invalid audio stream indices. Must be comma-separated integers.")
619
+
620
+ # validate stream selection options
621
+ if cli_args.audio_default_only and cli_args.audio_streams:
622
+ error("Cannot use both --audio-default-only and --audio-streams together.")
623
+
556
624
  ffmpeg_normalize = FFmpegNormalize(
557
625
  normalization_type=cli_args.normalization_type,
558
626
  target_level=cli_args.target_level,
@@ -586,6 +654,10 @@ def main() -> None:
586
654
  dry_run=cli_args.dry_run,
587
655
  progress=cli_args.progress,
588
656
  replaygain=cli_args.replaygain,
657
+ batch=cli_args.batch,
658
+ audio_streams=audio_streams,
659
+ audio_default_only=cli_args.audio_default_only,
660
+ keep_other_audio=cli_args.keep_other_audio,
589
661
  )
590
662
 
591
663
  if cli_args.output and len(cli_args.input) > len(cli_args.output):
@@ -84,6 +84,10 @@ class FFmpegNormalize:
84
84
  debug (bool, optional): Debug. Defaults to False.
85
85
  progress (bool, optional): Progress. Defaults to False.
86
86
  replaygain (bool, optional): Write ReplayGain tags without normalizing. Defaults to False.
87
+ batch (bool, optional): Preserve relative loudness between files (album mode). Defaults to False.
88
+ audio_streams (list[int] | None, optional): List of audio stream indices to normalize. Defaults to None (all streams).
89
+ audio_default_only (bool, optional): Only normalize audio streams with default disposition. Defaults to False.
90
+ keep_other_audio (bool, optional): Keep non-selected audio streams in output (copy without normalization). Defaults to False.
87
91
 
88
92
  Raises:
89
93
  FFmpegNormalizeError: If the ffmpeg executable is not found or does not support the loudnorm filter.
@@ -124,6 +128,10 @@ class FFmpegNormalize:
124
128
  debug: bool = False,
125
129
  progress: bool = False,
126
130
  replaygain: bool = False,
131
+ batch: bool = False,
132
+ audio_streams: list[int] | None = None,
133
+ audio_default_only: bool = False,
134
+ keep_other_audio: bool = False,
127
135
  ):
128
136
  self.ffmpeg_exe = get_ffmpeg_exe()
129
137
  self.has_loudnorm_capabilities = ffmpeg_has_loudnorm()
@@ -206,6 +214,12 @@ class FFmpegNormalize:
206
214
  self.debug = debug
207
215
  self.progress = progress
208
216
  self.replaygain = replaygain
217
+ self.batch = batch
218
+
219
+ # Stream selection options
220
+ self.audio_streams = audio_streams
221
+ self.audio_default_only = audio_default_only
222
+ self.keep_other_audio = keep_other_audio
209
223
 
210
224
  if (
211
225
  self.audio_codec is None or "pcm" in self.audio_codec
@@ -221,6 +235,19 @@ class FFmpegNormalize:
221
235
  "ReplayGain only works for EBU normalization type for now."
222
236
  )
223
237
 
238
+ # Validate stream selection options
239
+ if self.audio_streams is not None and self.audio_default_only:
240
+ raise FFmpegNormalizeError(
241
+ "Cannot use both audio_streams and audio_default_only together."
242
+ )
243
+
244
+ if self.keep_other_audio and self.keep_original_audio:
245
+ raise FFmpegNormalizeError(
246
+ "Cannot use both --keep-other-audio and --keep-original-audio together. "
247
+ "Use --keep-original-audio to keep all original streams alongside normalized ones, "
248
+ "or --keep-other-audio to keep only non-selected streams as passthrough."
249
+ )
250
+
224
251
  self.stats: list[LoudnessStatisticsWithMetadata] = []
225
252
  self.media_files: list[MediaFile] = []
226
253
  self.file_count = 0
@@ -248,29 +275,172 @@ class FFmpegNormalize:
248
275
  self.media_files.append(MediaFile(self, input_file, output_file))
249
276
  self.file_count += 1
250
277
 
278
+ def _calculate_batch_reference(self) -> float | None:
279
+ """
280
+ Calculate the batch reference loudness by averaging measurements across all files.
281
+
282
+ Returns:
283
+ float | None: The batch reference loudness value, or None if no measurements found.
284
+
285
+ Note:
286
+ TODO: Add option to specify different averaging methods (duration-weighted,
287
+ use quietest/loudest track, etc.)
288
+ """
289
+ measurements: list[float] = []
290
+
291
+ for media_file in self.media_files:
292
+ # Access audio streams from the streams dict
293
+ audio_streams = media_file.streams.get("audio", {})
294
+ for stream in audio_streams.values():
295
+ if self.normalization_type == "ebu":
296
+ # Get EBU integrated loudness from first pass
297
+ ebu_stats = stream.loudness_statistics.get("ebu_pass1")
298
+ if ebu_stats and "input_i" in ebu_stats:
299
+ measurements.append(float(ebu_stats["input_i"]))
300
+ elif self.normalization_type == "rms":
301
+ # Get RMS mean value
302
+ mean = stream.loudness_statistics.get("mean")
303
+ if mean is not None:
304
+ measurements.append(float(mean))
305
+ elif self.normalization_type == "peak":
306
+ # Get peak max value
307
+ max_val = stream.loudness_statistics.get("max")
308
+ if max_val is not None:
309
+ measurements.append(float(max_val))
310
+
311
+ if not measurements:
312
+ _logger.warning(
313
+ "No loudness measurements found for batch reference calculation. "
314
+ "Batch mode will not be applied."
315
+ )
316
+ return None
317
+
318
+ # Simple average of all measurements
319
+ batch_reference = sum(measurements) / len(measurements)
320
+ _logger.debug(f"Batch mode: Measurements for batch reference: {measurements}")
321
+ _logger.info(
322
+ f"Batch mode: Calculated reference loudness = {batch_reference:.2f} "
323
+ f"({self.normalization_type.upper()}, averaged from {len(measurements)} stream(s))"
324
+ )
325
+
326
+ return batch_reference
327
+
251
328
  def run_normalization(self) -> None:
252
329
  """
253
- Run the normalization procedures
330
+ Run the normalization procedures.
331
+
332
+ In batch mode, all files are analyzed first (first pass), then a batch reference
333
+ loudness is calculated, and finally all files are normalized (second pass) with
334
+ adjustments relative to the batch reference to preserve relative loudness.
335
+
336
+ In non-batch mode, each file is processed completely (both passes) before
337
+ moving to the next file.
254
338
  """
255
- for index, media_file in enumerate(
256
- tqdm(self.media_files, desc="File", disable=not self.progress, position=0)
257
- ):
339
+ if self.batch:
340
+ # Batch mode: analyze all files first, then normalize with relative adjustments
258
341
  _logger.info(
259
- f"Normalizing file {media_file} ({index + 1} of {self.file_count})"
342
+ f"Batch mode enabled: processing {self.file_count} file(s) while preserving relative loudness"
260
343
  )
261
344
 
262
- try:
263
- media_file.run_normalization()
264
- except Exception as e:
265
- if len(self.media_files) > 1:
266
- # simply warn and do not die
267
- _logger.error(
268
- f"Error processing input file {media_file}, will "
269
- f"continue batch-processing. Error was: {e}"
270
- )
271
- else:
272
- # raise the error so the program will exit
273
- raise e
345
+ # Recommend RMS/Peak for album normalization instead of EBU
346
+ if self.normalization_type == "ebu":
347
+ _logger.warning(
348
+ "Using EBU R128 normalization with --batch. For true album normalization where "
349
+ "all tracks are shifted by the same amount, consider using --normalization-type rms "
350
+ "or --normalization-type peak instead. EBU normalization applies different processing "
351
+ "to each track based on its loudness characteristics, which may alter relative levels "
352
+ "slightly due to psychoacoustic adjustments."
353
+ )
354
+
355
+ # Warn if using dynamic EBU mode with batch
356
+ if self.dynamic and self.normalization_type == "ebu":
357
+ _logger.warning(
358
+ "ffmpeg uses dynamic EBU normalization. This may change relative "
359
+ "loudness within a file. Use linear mode for true album normalization, or "
360
+ "switch to --normalization-type peak or --normalization-type rms instead. "
361
+ "To force linear mode, use --keep-lra-above-loudness-range-target or "
362
+ "--keep-loudness-range-target."
363
+ )
364
+
365
+ # Phase 1: Run first pass on all files to collect measurements
366
+ _logger.info("Phase 1: Analyzing all files...")
367
+ for index, media_file in enumerate(
368
+ tqdm(
369
+ self.media_files,
370
+ desc="Analysis",
371
+ disable=not self.progress,
372
+ position=0,
373
+ )
374
+ ):
375
+ _logger.info(
376
+ f"Analyzing file {media_file} ({index + 1} of {self.file_count})"
377
+ )
378
+
379
+ try:
380
+ # Only run first pass if not in dynamic EBU mode
381
+ if not (self.dynamic and self.normalization_type == "ebu"):
382
+ media_file._first_pass()
383
+ else:
384
+ _logger.debug(
385
+ "Dynamic EBU mode: First pass skipped for this file."
386
+ )
387
+ except Exception as e:
388
+ if len(self.media_files) > 1:
389
+ _logger.error(
390
+ f"Error analyzing input file {media_file}, will "
391
+ f"continue batch-processing. Error was: {e}"
392
+ )
393
+ else:
394
+ raise e
395
+
396
+ # Phase 2: Calculate batch reference loudness
397
+ batch_reference = self._calculate_batch_reference()
398
+
399
+ # Phase 3: Run second pass on all files with batch reference
400
+ _logger.info("Phase 2: Normalizing all files...")
401
+ for index, media_file in enumerate(
402
+ tqdm(
403
+ self.media_files,
404
+ desc="Normalization",
405
+ disable=not self.progress,
406
+ position=0,
407
+ )
408
+ ):
409
+ _logger.info(
410
+ f"Normalizing file {media_file} ({index + 1} of {self.file_count})"
411
+ )
412
+
413
+ try:
414
+ media_file.run_normalization(batch_reference=batch_reference)
415
+ except Exception as e:
416
+ if len(self.media_files) > 1:
417
+ _logger.error(
418
+ f"Error processing input file {media_file}, will "
419
+ f"continue batch-processing. Error was: {e}"
420
+ )
421
+ else:
422
+ raise e
423
+ else:
424
+ # Non-batch mode: process each file completely before moving to the next
425
+ for index, media_file in enumerate(
426
+ tqdm(
427
+ self.media_files, desc="File", disable=not self.progress, position=0
428
+ )
429
+ ):
430
+ _logger.info(
431
+ f"Normalizing file {media_file} ({index + 1} of {self.file_count})"
432
+ )
433
+
434
+ try:
435
+ media_file.run_normalization()
436
+ except Exception as e:
437
+ if len(self.media_files) > 1:
438
+ _logger.error(
439
+ f"Error processing input file {media_file}, will "
440
+ f"continue batch-processing. Error was: {e}"
441
+ )
442
+ else:
443
+ raise e
274
444
 
275
445
  if self.print_stats:
276
446
  json.dump(
@@ -86,6 +86,7 @@ class MediaFile:
86
86
  self.output_ext = current_ext
87
87
  self.streams: StreamDict = {"audio": {}, "video": {}, "subtitle": {}}
88
88
  self.temp_file: Union[str, None] = None
89
+ self.batch_reference: float | None = None
89
90
 
90
91
  self.parse_streams()
91
92
 
@@ -136,6 +137,18 @@ class MediaFile:
136
137
 
137
138
  output_lines = [line.strip() for line in output.split("\n")]
138
139
 
140
+ # First pass: parse disposition flags for each stream
141
+ stream_dispositions: dict[int, bool] = {}
142
+
143
+ for line in output_lines:
144
+ if line.startswith("Stream"):
145
+ if stream_id_match := re.search(r"#0:([\d]+)", line):
146
+ stream_id = int(stream_id_match.group(1))
147
+ # Check if (default) appears on the Stream line
148
+ is_default = "(default)" in line
149
+ stream_dispositions[stream_id] = is_default
150
+
151
+ # Second pass: parse stream information
139
152
  duration = None
140
153
  for line in output_lines:
141
154
  if "Duration" in line:
@@ -155,8 +168,12 @@ class MediaFile:
155
168
  else:
156
169
  continue
157
170
 
171
+ is_default = stream_dispositions.get(stream_id, False)
172
+
158
173
  if "Audio" in line:
159
- _logger.debug(f"Found audio stream at index {stream_id}")
174
+ _logger.debug(
175
+ f"Found audio stream at index {stream_id} (default: {is_default})"
176
+ )
160
177
  sample_rate_match = re.search(r"(\d+) Hz", line)
161
178
  sample_rate = (
162
179
  int(sample_rate_match.group(1)) if sample_rate_match else None
@@ -170,6 +187,7 @@ class MediaFile:
170
187
  sample_rate,
171
188
  bit_depth,
172
189
  duration,
190
+ is_default,
173
191
  )
174
192
 
175
193
  elif "Video" in line:
@@ -201,21 +219,82 @@ class MediaFile:
201
219
  self.streams["video"] = {}
202
220
  self.streams["subtitle"] = {}
203
221
 
204
- def run_normalization(self) -> None:
222
+ def _get_streams_to_normalize(self) -> list[AudioStream]:
223
+ """
224
+ Determine which audio streams to normalize based on configuration.
225
+
226
+ Returns:
227
+ list[AudioStream]: List of audio streams to normalize
228
+ """
229
+ all_audio_streams = list(self.streams["audio"].values())
230
+
231
+ if self.ffmpeg_normalize.audio_streams is not None:
232
+ # User specified specific stream indices
233
+ selected_streams = [
234
+ stream
235
+ for stream in all_audio_streams
236
+ if stream.stream_id in self.ffmpeg_normalize.audio_streams
237
+ ]
238
+ if not selected_streams:
239
+ _logger.warning(
240
+ f"No audio streams found matching indices {self.ffmpeg_normalize.audio_streams}. "
241
+ f"Available streams: {[s.stream_id for s in all_audio_streams]}"
242
+ )
243
+ else:
244
+ _logger.info(
245
+ f"Normalizing selected audio streams: {[s.stream_id for s in selected_streams]}"
246
+ )
247
+ return selected_streams
248
+
249
+ elif self.ffmpeg_normalize.audio_default_only:
250
+ # Only normalize streams with default disposition
251
+ default_streams = [
252
+ stream for stream in all_audio_streams if stream.is_default
253
+ ]
254
+ if not default_streams:
255
+ _logger.warning(
256
+ "No audio streams with 'default' disposition found. "
257
+ f"Available streams: {[s.stream_id for s in all_audio_streams]}"
258
+ )
259
+ else:
260
+ _logger.info(
261
+ f"Normalizing default audio streams: {[s.stream_id for s in default_streams]}"
262
+ )
263
+ return default_streams
264
+
265
+ else:
266
+ # Normalize all streams (default behavior)
267
+ return all_audio_streams
268
+
269
+ def run_normalization(self, batch_reference: float | None = None) -> None:
205
270
  """
206
271
  Run the normalization process for this file.
272
+
273
+ Args:
274
+ batch_reference (float | None, optional): Reference loudness for batch mode.
275
+ If provided, the first pass is skipped (assumed already done) and this
276
+ reference is used to calculate relative adjustments. Defaults to None.
207
277
  """
208
278
  _logger.debug(f"Running normalization for {self.input_file}")
209
279
 
210
- # run the first pass to get loudness stats, unless in dynamic EBU mode
211
- if not (
212
- self.ffmpeg_normalize.dynamic
213
- and self.ffmpeg_normalize.normalization_type == "ebu"
214
- ):
215
- self._first_pass()
280
+ # Store batch reference for use in second pass
281
+ self.batch_reference = batch_reference
282
+
283
+ # run the first pass to get loudness stats, unless in dynamic EBU mode or batch mode
284
+ # (in batch mode, first pass is already done in FFmpegNormalize.run_normalization)
285
+ if batch_reference is None:
286
+ if not (
287
+ self.ffmpeg_normalize.dynamic
288
+ and self.ffmpeg_normalize.normalization_type == "ebu"
289
+ ):
290
+ self._first_pass()
291
+ else:
292
+ _logger.debug(
293
+ "Dynamic EBU mode: First pass will not run, as it is not needed."
294
+ )
216
295
  else:
217
296
  _logger.debug(
218
- "Dynamic EBU mode: First pass will not run, as it is not needed."
297
+ f"Batch mode: Skipping first pass (already completed), using batch reference = {batch_reference:.2f}"
219
298
  )
220
299
 
221
300
  # for second pass, create a temp file
@@ -400,7 +479,9 @@ class MediaFile:
400
479
  """
401
480
  _logger.debug(f"Parsing normalization info for {self.input_file}")
402
481
 
403
- for index, audio_stream in enumerate(self.streams["audio"].values()):
482
+ streams_to_normalize = self._get_streams_to_normalize()
483
+
484
+ for index, audio_stream in enumerate(streams_to_normalize):
404
485
  if self.ffmpeg_normalize.normalization_type == "ebu":
405
486
  fun = getattr(audio_stream, "parse_loudnorm_stats")
406
487
  else:
@@ -410,7 +491,7 @@ class MediaFile:
410
491
  with tqdm(
411
492
  total=100,
412
493
  position=1,
413
- desc=f"Stream {index + 1}/{len(self.streams['audio'].values())}",
494
+ desc=f"Stream {index + 1}/{len(streams_to_normalize)}",
414
495
  bar_format=TQDM_BAR_FORMAT,
415
496
  ) as pbar:
416
497
  for progress in fun():
@@ -429,7 +510,9 @@ class MediaFile:
429
510
  filter_chains = []
430
511
  output_labels = []
431
512
 
432
- for audio_stream in self.streams["audio"].values():
513
+ streams_to_normalize = self._get_streams_to_normalize()
514
+
515
+ for audio_stream in streams_to_normalize:
433
516
  skip_normalization = False
434
517
  if self.ffmpeg_normalize.lower_only:
435
518
  if self.ffmpeg_normalize.normalization_type == "ebu":
@@ -461,9 +544,13 @@ class MediaFile:
461
544
  normalization_filter = "acopy"
462
545
  else:
463
546
  if self.ffmpeg_normalize.normalization_type == "ebu":
464
- normalization_filter = audio_stream.get_second_pass_opts_ebu()
547
+ normalization_filter = audio_stream.get_second_pass_opts_ebu(
548
+ batch_reference=self.batch_reference
549
+ )
465
550
  else:
466
- normalization_filter = audio_stream.get_second_pass_opts_peakrms()
551
+ normalization_filter = audio_stream.get_second_pass_opts_peakrms(
552
+ batch_reference=self.batch_reference
553
+ )
467
554
 
468
555
  input_label = f"[0:{audio_stream.stream_id}]"
469
556
  output_label = f"[norm{audio_stream.stream_id}]"
@@ -551,29 +638,66 @@ class MediaFile:
551
638
  f"The chosen output extension {self.output_ext} does not support video/cover art. It will be disabled."
552
639
  )
553
640
 
641
+ # Determine streams to normalize and passthrough
642
+ streams_to_normalize = self._get_streams_to_normalize()
643
+ all_audio_streams = list(self.streams["audio"].values())
644
+
645
+ # Determine which streams to passthrough
646
+ if self.ffmpeg_normalize.keep_other_audio and (
647
+ self.ffmpeg_normalize.audio_streams is not None
648
+ or self.ffmpeg_normalize.audio_default_only
649
+ ):
650
+ streams_to_passthrough = [
651
+ s for s in all_audio_streams if s not in streams_to_normalize
652
+ ]
653
+ else:
654
+ streams_to_passthrough = []
655
+
554
656
  # ... and map the output of the normalization filters
555
657
  for ol in output_labels:
556
658
  cmd.extend(["-map", ol])
557
659
 
558
- # set audio codec (never copy)
559
- if self.ffmpeg_normalize.audio_codec:
560
- cmd.extend(["-c:a", self.ffmpeg_normalize.audio_codec])
561
- else:
562
- for index, (_, audio_stream) in enumerate(self.streams["audio"].items()):
563
- cmd.extend([f"-c:a:{index}", audio_stream.get_pcm_codec()])
660
+ # ... and map passthrough audio streams (copy without normalization)
661
+ for stream in streams_to_passthrough:
662
+ cmd.extend(["-map", f"0:{stream.stream_id}"])
663
+
664
+ # Track output audio stream index for codec assignment
665
+ output_audio_idx = 0
564
666
 
565
- # other audio options (if any)
667
+ # set audio codec for normalized streams
668
+ for audio_stream in streams_to_normalize:
669
+ if self.ffmpeg_normalize.audio_codec:
670
+ codec = self.ffmpeg_normalize.audio_codec
671
+ else:
672
+ codec = audio_stream.get_pcm_codec()
673
+ cmd.extend([f"-c:a:{output_audio_idx}", codec])
674
+ output_audio_idx += 1
675
+
676
+ # set audio codec for passthrough streams (always copy)
677
+ for _ in streams_to_passthrough:
678
+ cmd.extend([f"-c:a:{output_audio_idx}", "copy"])
679
+ output_audio_idx += 1
680
+
681
+ # other audio options (if any) - only apply to normalized streams
566
682
  if self.ffmpeg_normalize.audio_bitrate:
567
683
  if self.ffmpeg_normalize.audio_codec == "libvorbis":
568
684
  # libvorbis takes just a "-b" option, for some reason
569
685
  # https://github.com/slhck/ffmpeg-normalize/issues/277
570
686
  cmd.extend(["-b", str(self.ffmpeg_normalize.audio_bitrate)])
571
687
  else:
572
- cmd.extend(["-b:a", str(self.ffmpeg_normalize.audio_bitrate)])
688
+ # Only apply to normalized streams
689
+ for idx in range(len(streams_to_normalize)):
690
+ cmd.extend(
691
+ [f"-b:a:{idx}", str(self.ffmpeg_normalize.audio_bitrate)]
692
+ )
573
693
  if self.ffmpeg_normalize.sample_rate:
574
- cmd.extend(["-ar", str(self.ffmpeg_normalize.sample_rate)])
694
+ # Only apply to normalized streams
695
+ for idx in range(len(streams_to_normalize)):
696
+ cmd.extend([f"-ar:a:{idx}", str(self.ffmpeg_normalize.sample_rate)])
575
697
  if self.ffmpeg_normalize.audio_channels:
576
- cmd.extend(["-ac", str(self.ffmpeg_normalize.audio_channels)])
698
+ # Only apply to normalized streams
699
+ for idx in range(len(streams_to_normalize)):
700
+ cmd.extend([f"-ac:a:{idx}", str(self.ffmpeg_normalize.audio_channels)])
577
701
 
578
702
  # ... and subtitles
579
703
  if not self.ffmpeg_normalize.subtitle_disable:
@@ -583,10 +707,11 @@ class MediaFile:
583
707
  cmd.extend(["-c:s", "copy"])
584
708
 
585
709
  if self.ffmpeg_normalize.keep_original_audio:
586
- highest_index = len(self.streams["audio"])
710
+ # Map all original audio streams after normalized and passthrough streams
587
711
  for index, _ in enumerate(self.streams["audio"].items()):
588
712
  cmd.extend(["-map", f"0:a:{index}"])
589
- cmd.extend([f"-c:a:{highest_index + index}", "copy"])
713
+ cmd.extend([f"-c:a:{output_audio_idx}", "copy"])
714
+ output_audio_idx += 1
590
715
 
591
716
  # extra options (if any)
592
717
  if self.ffmpeg_normalize.extra_output_options:
@@ -645,13 +770,14 @@ class MediaFile:
645
770
  ebu_pass_2_stats = list(
646
771
  AudioStream.prune_and_parse_loudnorm_output(output).values()
647
772
  )
648
- # Only set second pass stats if they exist (they might not if all streams were skipped with --lower-only)
649
- if len(ebu_pass_2_stats) == len(self.streams["audio"]):
650
- for idx, audio_stream in enumerate(self.streams["audio"].values()):
773
+ # Only set second pass stats for streams that were actually normalized
774
+ streams_to_normalize = self._get_streams_to_normalize()
775
+ if len(ebu_pass_2_stats) == len(streams_to_normalize):
776
+ for idx, audio_stream in enumerate(streams_to_normalize):
651
777
  audio_stream.set_second_pass_stats(ebu_pass_2_stats[idx])
652
778
  else:
653
779
  _logger.debug(
654
- f"Expected {len(self.streams['audio'])} EBU pass 2 statistics but got {len(ebu_pass_2_stats)}. "
780
+ f"Expected {len(streams_to_normalize)} EBU pass 2 statistics but got {len(ebu_pass_2_stats)}. "
655
781
  "This can happen when normalization is skipped (e.g., with --lower-only)."
656
782
  )
657
783
 
@@ -99,6 +99,7 @@ class AudioStream(MediaStream):
99
99
  sample_rate: int | None,
100
100
  bit_depth: int | None,
101
101
  duration: float | None,
102
+ is_default: bool = False,
102
103
  ):
103
104
  """
104
105
  Create an AudioStream object.
@@ -110,6 +111,7 @@ class AudioStream(MediaStream):
110
111
  sample_rate (int): sample rate in Hz
111
112
  bit_depth (int): bit depth in bits
112
113
  duration (float): duration in seconds
114
+ is_default (bool): Whether this stream has the default disposition flag
113
115
  """
114
116
  super().__init__(ffmpeg_normalize, media_file, "audio", stream_id)
115
117
 
@@ -124,6 +126,7 @@ class AudioStream(MediaStream):
124
126
  self.bit_depth = bit_depth
125
127
 
126
128
  self.duration = duration
129
+ self.is_default = is_default
127
130
 
128
131
  @staticmethod
129
132
  def _constrain(
@@ -441,9 +444,14 @@ class AudioStream(MediaStream):
441
444
  )
442
445
  return result
443
446
 
444
- def get_second_pass_opts_ebu(self) -> str:
447
+ def get_second_pass_opts_ebu(self, batch_reference: float | None = None) -> str:
445
448
  """
446
449
  Return second pass loudnorm filter options string for ffmpeg
450
+
451
+ Args:
452
+ batch_reference (float | None, optional): Reference loudness for batch mode.
453
+ When provided, the target level is adjusted to preserve relative loudness.
454
+ Defaults to None.
447
455
  """
448
456
 
449
457
  # In dynamic mode, we can do everything in one pass, and we do not have first pass stats
@@ -549,6 +557,19 @@ class AudioStream(MediaStream):
549
557
 
550
558
  stats = self.loudness_statistics["ebu_pass1"]
551
559
 
560
+ # Adjust target level for batch mode to preserve relative loudness
561
+ if batch_reference is not None:
562
+ input_i = float(stats["input_i"])
563
+ # Formula: adjusted_target = target_level + (input_i - batch_reference)
564
+ # If track is quieter than average (input_i < batch_ref), offset is negative → quieter target
565
+ # If track is louder than average (input_i > batch_ref), offset is positive → louder target
566
+ adjusted_target = target_level + (input_i - batch_reference)
567
+ _logger.info(
568
+ f"Batch mode: Adjusting target from {target_level:.2f} to {adjusted_target:.2f} LUFS "
569
+ f"(input_i={input_i:.2f}, batch_ref={batch_reference:.2f}, offset={input_i - batch_reference:.2f})"
570
+ )
571
+ target_level = adjusted_target
572
+
552
573
  opts = {
553
574
  "i": target_level,
554
575
  "lra": self.media_file.ffmpeg_normalize.loudness_range_target,
@@ -573,11 +594,16 @@ class AudioStream(MediaStream):
573
594
 
574
595
  return "loudnorm=" + dict_to_filter_opts(opts)
575
596
 
576
- def get_second_pass_opts_peakrms(self) -> str:
597
+ def get_second_pass_opts_peakrms(self, batch_reference: float | None = None) -> str:
577
598
  """
578
599
  Set the adjustment gain based on chosen option and mean/max volume,
579
600
  return the matching ffmpeg volume filter.
580
601
 
602
+ Args:
603
+ batch_reference (float | None, optional): Reference loudness for batch mode.
604
+ When provided, the target level is adjusted to preserve relative loudness.
605
+ Defaults to None.
606
+
581
607
  Returns:
582
608
  str: ffmpeg volume filter string
583
609
  """
@@ -592,6 +618,27 @@ class AudioStream(MediaStream):
592
618
  normalization_type = self.media_file.ffmpeg_normalize.normalization_type
593
619
  target_level = self.media_file.ffmpeg_normalize.target_level
594
620
 
621
+ # Adjust target level for batch mode to preserve relative loudness
622
+ if batch_reference is not None:
623
+ if normalization_type == "peak":
624
+ measured_level = float(self.loudness_statistics["max"])
625
+ elif normalization_type == "rms":
626
+ measured_level = float(self.loudness_statistics["mean"])
627
+ else:
628
+ raise FFmpegNormalizeError(
629
+ "Can only set adjustment for peak and RMS normalization"
630
+ )
631
+
632
+ # Formula: adjusted_target = target_level + (measured_level - batch_reference)
633
+ # If track is quieter than average (measured < batch_ref), offset is negative → quieter target
634
+ # If track is louder than average (measured > batch_ref), offset is positive → louder target
635
+ adjusted_target = target_level + (measured_level - batch_reference)
636
+ _logger.info(
637
+ f"Batch mode: Adjusting target from {target_level:.2f} to {adjusted_target:.2f} dB "
638
+ f"(measured={measured_level:.2f}, batch_ref={batch_reference:.2f}, offset={measured_level - batch_reference:.2f})"
639
+ )
640
+ target_level = adjusted_target
641
+
595
642
  if normalization_type == "peak":
596
643
  adjustment = 0 + target_level - self.loudness_statistics["max"]
597
644
  elif normalization_type == "rms":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ffmpeg-normalize
3
- Version: 1.33.4
3
+ Version: 1.35.0
4
4
  Summary: Normalize audio via ffmpeg
5
5
  Keywords: ffmpeg,normalize,audio
6
6
  Author: Werner Robitza
@@ -33,7 +33,7 @@ Description-Content-Type: text/markdown
33
33
  # ffmpeg-normalize
34
34
 
35
35
  [![PyPI version](https://img.shields.io/pypi/v/ffmpeg-normalize.svg)](https://pypi.org/project/ffmpeg-normalize)
36
- ![Docker Image Version](https://img.shields.io/docker/v/slhck/ffmpeg-normalize?sort=semver&label=Docker%20image)
36
+ [![Docker Image Version](https://img.shields.io/docker/v/slhck/ffmpeg-normalize?sort=semver&label=Docker%20image)](https://hub.docker.com/r/slhck/ffmpeg-normalize)
37
37
  ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/slhck/ffmpeg-normalize/python-package.yml)
38
38
 
39
39
  <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
@@ -46,12 +46,48 @@ This program normalizes media files to a certain loudness level using the EBU R1
46
46
 
47
47
  ## ✨ Features
48
48
 
49
- - EBU R128 loudness normalization (two-pass by default, with an option for one-pass dynamic normalization)
50
- - RMS-based normalization
51
- - Peak normalization
52
- - Video file support
53
- - Docker support
54
- - Python API
49
+ - EBU R128 loudness normalization — Two-pass by default, with an option for one-pass dynamic normalization
50
+ - RMS-based normalization — Adjust audio to a specific RMS level
51
+ - Peak normalization — Adjust audio to a specific peak level
52
+ - Selective audio stream normalization — Normalize specific audio streams or only default streams
53
+ - Video file support — Process video files while preserving video streams
54
+ - Docker support — Run via Docker container
55
+ - Python API — Use programmatically in your Python projects
56
+ - Shell completions — Available for bash, zsh, and fish
57
+ - Album Batch normalization – Process files jointy, preserving relative loudness
58
+
59
+ ## 🆕 What's New
60
+
61
+ - Version 1.35.0 has **batch/album normalization** with `--batch`. It preserves relative loudness between files! Perfect for music albums where you want to shift all tracks by the same amount.
62
+
63
+ Example:
64
+
65
+ ```bash
66
+ ffmpeg-normalize album/*.flac --batch -nt rms -t -20
67
+ ```
68
+
69
+ shifts the entire album so the average RMS is -20 dB, preserving the original relative loudness as mastered.
70
+
71
+ - Version 1.34.0 brings **selective audio stream normalization**! You can now:
72
+
73
+ - Normalize specific audio streams with `-as/--audio-streams` (e.g., `-as 1,2` to normalize only streams 1 and 2)
74
+ - Normalize only default audio streams with `--audio-default-only` (useful for files with multiple language tracks)
75
+ - Keep other streams unchanged with `--keep-other-audio` (copy non-selected streams without normalization)
76
+
77
+ Example:
78
+
79
+ ```bash
80
+ ffmpeg-normalize input.mkv -as 1 --keep-other-audio
81
+ ```
82
+
83
+ normalizes stream 1 and copies all other audio streams unchanged.
84
+
85
+ Other recent additions:
86
+
87
+ - **Shell completions** (v1.31.0) — Tab completion for bash, zsh, and fish shells. See the [installation guide](https://slhck.info/ffmpeg-normalize/getting-started/installation/#shell-completions) for setup instructions.
88
+ - **`--lower-only` option** — Prevent audio from increasing in loudness, only lower it if needed (works with all normalization types).
89
+
90
+ See the [full changelog](https://github.com/slhck/ffmpeg-normalize/blob/master/CHANGELOG.md) for all updates.
55
91
 
56
92
  ## 🚀 Quick Start
57
93
 
@@ -0,0 +1,14 @@
1
+ ffmpeg_normalize/__init__.py,sha256=l0arjiMMBNbiH3IH67gT6SdZjPGAVLAdorUx38dNtvE,508
2
+ ffmpeg_normalize/__main__.py,sha256=ooM9MZRI4BfyF2G8eioY5KcumZ3jELRBkGNNchmF_ck,23788
3
+ ffmpeg_normalize/_cmd_utils.py,sha256=1JspVpguAPsq7DqvyvjUNzHhVv8J3X93xNOMwito_jY,5284
4
+ ffmpeg_normalize/_errors.py,sha256=brTQ4osJ4fTA8wnyMPVVYfGwJ0wqeShRFydTEwi_VEY,48
5
+ ffmpeg_normalize/_ffmpeg_normalize.py,sha256=NvFhiiYHLJz8M78WGEIit4Qbe-XsjLlrXvzKuNMW7v8,19973
6
+ ffmpeg_normalize/_logger.py,sha256=3Ap4Fxg7xGrzz7h4IGuNEf0KKstx0Rq_eLbHPrHzcrI,1841
7
+ ffmpeg_normalize/_media_file.py,sha256=awznS5C8ph6Mjy5dzwT-ubBpB3MXwbO94QFcn7mBejY,32197
8
+ ffmpeg_normalize/_streams.py,sha256=4Dnzuunhqz2qsOhlDv0dKML-lLjmPmUmM7M4dpn66Ow,24910
9
+ ffmpeg_normalize/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ ffmpeg_normalize-1.35.0.dist-info/licenses/LICENSE.md,sha256=ig-_YggmJGbPQC_gUgBNFa0_XMsuHTpocivFnlOF4tE,1082
11
+ ffmpeg_normalize-1.35.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
12
+ ffmpeg_normalize-1.35.0.dist-info/entry_points.txt,sha256=1bdrW7-kJRc8tctjnGcfe_Fwx39z5JOm0yZnJHnmwl8,69
13
+ ffmpeg_normalize-1.35.0.dist-info/METADATA,sha256=Z4VJQhRECSX51YKaJFQkgY5CqaPxlgd6GIlyNYRFmSU,13387
14
+ ffmpeg_normalize-1.35.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.8.16
2
+ Generator: uv 0.8.24
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,14 +0,0 @@
1
- ffmpeg_normalize/__init__.py,sha256=l0arjiMMBNbiH3IH67gT6SdZjPGAVLAdorUx38dNtvE,508
2
- ffmpeg_normalize/__main__.py,sha256=UJIaAel7DSWd0eJr0FlZupsaHvj4wwrQsWd7fmBWB8s,20965
3
- ffmpeg_normalize/_cmd_utils.py,sha256=1JspVpguAPsq7DqvyvjUNzHhVv8J3X93xNOMwito_jY,5284
4
- ffmpeg_normalize/_errors.py,sha256=brTQ4osJ4fTA8wnyMPVVYfGwJ0wqeShRFydTEwi_VEY,48
5
- ffmpeg_normalize/_ffmpeg_normalize.py,sha256=_ZK2P3kAM0mnxY3iCYH61T6jhMndTdO2Rqh03vJo7rY,11852
6
- ffmpeg_normalize/_logger.py,sha256=3Ap4Fxg7xGrzz7h4IGuNEf0KKstx0Rq_eLbHPrHzcrI,1841
7
- ffmpeg_normalize/_media_file.py,sha256=eJt9uNXmJheSH4B0ZZARFPMOwN0BGGXOELhcDpWmeew,26860
8
- ffmpeg_normalize/_streams.py,sha256=XPM539yS220cOrCz0aAiKgoIcStbBUvR4-E0J-7uyOg,22174
9
- ffmpeg_normalize/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- ffmpeg_normalize-1.33.4.dist-info/licenses/LICENSE.md,sha256=ig-_YggmJGbPQC_gUgBNFa0_XMsuHTpocivFnlOF4tE,1082
11
- ffmpeg_normalize-1.33.4.dist-info/WHEEL,sha256=F3mArEuDT3LDFEqo9fCiUx6ISLN64aIhcGSiIwtu4r8,79
12
- ffmpeg_normalize-1.33.4.dist-info/entry_points.txt,sha256=1bdrW7-kJRc8tctjnGcfe_Fwx39z5JOm0yZnJHnmwl8,69
13
- ffmpeg_normalize-1.33.4.dist-info/METADATA,sha256=01HzoufSfqquEDkixSDTGiOWZq0FsU0ZmGZhM-kG_Mg,11374
14
- ffmpeg_normalize-1.33.4.dist-info/RECORD,,