ffmpeg-normalize 1.41.1__tar.gz → 1.42.1__tar.gz

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 (18) hide show
  1. {ffmpeg_normalize-1.41.1 → ffmpeg_normalize-1.42.1}/PKG-INFO +18 -3
  2. {ffmpeg_normalize-1.41.1 → ffmpeg_normalize-1.42.1}/README.md +16 -2
  3. ffmpeg_normalize-1.42.1/pyproject.toml +69 -0
  4. ffmpeg_normalize-1.41.1/pyproject.toml → ffmpeg_normalize-1.42.1/pyproject.toml.orig +2 -1
  5. {ffmpeg_normalize-1.41.1 → ffmpeg_normalize-1.42.1}/src/ffmpeg_normalize/__main__.py +18 -0
  6. {ffmpeg_normalize-1.41.1 → ffmpeg_normalize-1.42.1}/src/ffmpeg_normalize/_cmd_utils.py +11 -8
  7. {ffmpeg_normalize-1.41.1 → ffmpeg_normalize-1.42.1}/src/ffmpeg_normalize/_ffmpeg_normalize.py +10 -0
  8. {ffmpeg_normalize-1.41.1 → ffmpeg_normalize-1.42.1}/src/ffmpeg_normalize/_media_file.py +124 -58
  9. {ffmpeg_normalize-1.41.1 → ffmpeg_normalize-1.42.1}/LICENSE.md +0 -0
  10. {ffmpeg_normalize-1.41.1 → ffmpeg_normalize-1.42.1}/src/ffmpeg_normalize/__init__.py +0 -0
  11. {ffmpeg_normalize-1.41.1 → ffmpeg_normalize-1.42.1}/src/ffmpeg_normalize/_errors.py +0 -0
  12. {ffmpeg_normalize-1.41.1 → ffmpeg_normalize-1.42.1}/src/ffmpeg_normalize/_logger.py +0 -0
  13. {ffmpeg_normalize-1.41.1 → ffmpeg_normalize-1.42.1}/src/ffmpeg_normalize/_presets.py +0 -0
  14. {ffmpeg_normalize-1.41.1 → ffmpeg_normalize-1.42.1}/src/ffmpeg_normalize/_streams.py +0 -0
  15. {ffmpeg_normalize-1.41.1 → ffmpeg_normalize-1.42.1}/src/ffmpeg_normalize/data/presets/music.json +0 -0
  16. {ffmpeg_normalize-1.41.1 → ffmpeg_normalize-1.42.1}/src/ffmpeg_normalize/data/presets/podcast.json +0 -0
  17. {ffmpeg_normalize-1.41.1 → ffmpeg_normalize-1.42.1}/src/ffmpeg_normalize/data/presets/streaming-video.json +0 -0
  18. {ffmpeg_normalize-1.41.1 → ffmpeg_normalize-1.42.1}/src/ffmpeg_normalize/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ffmpeg-normalize
3
- Version: 1.41.1
3
+ Version: 1.42.1
4
4
  Summary: Normalize audio via ffmpeg
5
5
  Keywords: ffmpeg,normalize,audio
6
6
  Author: Werner Robitza
@@ -19,6 +19,7 @@ Classifier: Programming Language :: Python :: 3.11
19
19
  Classifier: Programming Language :: Python :: 3.12
20
20
  Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Programming Language :: Python :: 3.15
22
23
  Requires-Dist: tqdm>=4.64.1
23
24
  Requires-Dist: colorama>=0.4.6 ; sys_platform == 'win32'
24
25
  Requires-Dist: ffmpeg-progress-yield>=1.0.1
@@ -29,14 +30,18 @@ Project-URL: Homepage, https://github.com/slhck/ffmpeg-normalize
29
30
  Project-URL: Repository, https://github.com/slhck/ffmpeg-normalize
30
31
  Description-Content-Type: text/markdown
31
32
 
32
- # ffmpeg-normalize
33
+ <h1 align="center">ffmpeg-normalize</h1>
34
+
35
+ <p align="center">
36
+ <img src="icon.png" alt="ffmpeg-normalize logo" width="160" height="160">
37
+ </p>
33
38
 
34
39
  [![PyPI version](https://img.shields.io/pypi/v/ffmpeg-normalize.svg)](https://pypi.org/project/ffmpeg-normalize)
35
40
  [![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)
36
41
  ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/slhck/ffmpeg-normalize/python-package.yml)
37
42
 
38
43
  <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
39
- [![All Contributors](https://img.shields.io/badge/all_contributors-23-orange.svg?style=flat-square)](#contributors-)
44
+ [![All Contributors](https://img.shields.io/badge/all_contributors-24-orange.svg?style=flat-square)](#contributors-)
40
45
  <!-- ALL-CONTRIBUTORS-BADGE:END -->
41
46
 
42
47
  A utility for batch-normalizing audio using ffmpeg.
@@ -50,6 +55,7 @@ This program normalizes media files to a certain loudness level using the EBU R1
50
55
  - Peak normalization — Adjust audio to a specific peak level
51
56
  - Selective audio stream normalization — Normalize specific audio streams or only default streams
52
57
  - Skip files already at target — Avoid re-encoding files already within a threshold of the target level
58
+ - Self-documenting output — Optionally record the effective encoder settings as an `ENCODER_SETTINGS` tag
53
59
  - Video file support — Process video files while preserving video streams
54
60
  - Docker support — Run via Docker container
55
61
  - Python API — Use programmatically in your Python projects
@@ -65,6 +71,14 @@ This program normalizes media files to a certain loudness level using the EBU R1
65
71
 
66
72
  ## 🆕 What's New
67
73
 
74
+ - Version 1.42.0 can optionally write an **`ENCODER_SETTINGS`** tag on each normalized audio stream via `--write-encoder-settings`. The tag holds an ffmpeg-command-equivalent string — the audio codec, bitrate, sample rate and channels, plus the normalization filter including the measured loudnorm values — so a normalized file documents how it was produced. This is a Matroska/WebM convention and has no effect with `--metadata-disable`.
75
+
76
+ Example:
77
+
78
+ ```bash
79
+ ffmpeg-normalize input.wav -c:a libopus -b:a 128k --write-encoder-settings -o output.mkv
80
+ ```
81
+
68
82
  - Version 1.41.0 automatically picks the correct output audio codec for the output container, so you no longer need to specify `-c:a`/`--audio-codec` unless you want to override the default. PCM is chosen for containers that support it; others will use teh default that ffmpeg picks. See [the usage guide](https://slhck.info/ffmpeg-normalize/usage/file-input-output/#how-the-output-audio-codec-is-chosen) for details.
69
83
 
70
84
  - Version 1.40.0 can optionally **skip files that are already at the target level** via `--threshold` (e.g. `--threshold 0.5`, disabled by default). Such files are copied through unchanged instead of being re-encoded. The `--print-stats` output now includes a per-file `status` (`normalized`, `skipped`, or `error`, plus an `error` message on failure), and the exit code is non-zero if any file failed to process, so a script can tell what happened to each file.
@@ -169,6 +183,7 @@ The only reason this project exists in its current form is because [@benjaoming]
169
183
  <tr>
170
184
  <td align="center" valign="top" width="14.28%"><a href="https://davidbern.com/"><img src="https://avatars.githubusercontent.com/u/371066?v=4?s=100" width="100px;" alt="David Bern"/><br /><sub><b>David Bern</b></sub></a><br /><a href="https://github.com/slhck/ffmpeg-normalize/commits?author=odie5533" title="Code">💻</a></td>
171
185
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/randompersona1"><img src="https://avatars.githubusercontent.com/u/74961116?v=4?s=100" width="100px;" alt="randompersona1"/><br /><sub><b>randompersona1</b></sub></a><br /><a href="https://github.com/slhck/ffmpeg-normalize/commits?author=randompersona1" title="Code">💻</a></td>
186
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/ppetr"><img src="https://avatars.githubusercontent.com/u/163061?v=4?s=100" width="100px;" alt="Petr"/><br /><sub><b>Petr</b></sub></a><br /><a href="https://github.com/slhck/ffmpeg-normalize/commits?author=ppetr" title="Code">💻</a></td>
172
187
  </tr>
173
188
  </tbody>
174
189
  <tfoot>
@@ -1,11 +1,15 @@
1
- # ffmpeg-normalize
1
+ <h1 align="center">ffmpeg-normalize</h1>
2
+
3
+ <p align="center">
4
+ <img src="icon.png" alt="ffmpeg-normalize logo" width="160" height="160">
5
+ </p>
2
6
 
3
7
  [![PyPI version](https://img.shields.io/pypi/v/ffmpeg-normalize.svg)](https://pypi.org/project/ffmpeg-normalize)
4
8
  [![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)
5
9
  ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/slhck/ffmpeg-normalize/python-package.yml)
6
10
 
7
11
  <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
8
- [![All Contributors](https://img.shields.io/badge/all_contributors-23-orange.svg?style=flat-square)](#contributors-)
12
+ [![All Contributors](https://img.shields.io/badge/all_contributors-24-orange.svg?style=flat-square)](#contributors-)
9
13
  <!-- ALL-CONTRIBUTORS-BADGE:END -->
10
14
 
11
15
  A utility for batch-normalizing audio using ffmpeg.
@@ -19,6 +23,7 @@ This program normalizes media files to a certain loudness level using the EBU R1
19
23
  - Peak normalization — Adjust audio to a specific peak level
20
24
  - Selective audio stream normalization — Normalize specific audio streams or only default streams
21
25
  - Skip files already at target — Avoid re-encoding files already within a threshold of the target level
26
+ - Self-documenting output — Optionally record the effective encoder settings as an `ENCODER_SETTINGS` tag
22
27
  - Video file support — Process video files while preserving video streams
23
28
  - Docker support — Run via Docker container
24
29
  - Python API — Use programmatically in your Python projects
@@ -34,6 +39,14 @@ This program normalizes media files to a certain loudness level using the EBU R1
34
39
 
35
40
  ## 🆕 What's New
36
41
 
42
+ - Version 1.42.0 can optionally write an **`ENCODER_SETTINGS`** tag on each normalized audio stream via `--write-encoder-settings`. The tag holds an ffmpeg-command-equivalent string — the audio codec, bitrate, sample rate and channels, plus the normalization filter including the measured loudnorm values — so a normalized file documents how it was produced. This is a Matroska/WebM convention and has no effect with `--metadata-disable`.
43
+
44
+ Example:
45
+
46
+ ```bash
47
+ ffmpeg-normalize input.wav -c:a libopus -b:a 128k --write-encoder-settings -o output.mkv
48
+ ```
49
+
37
50
  - Version 1.41.0 automatically picks the correct output audio codec for the output container, so you no longer need to specify `-c:a`/`--audio-codec` unless you want to override the default. PCM is chosen for containers that support it; others will use teh default that ffmpeg picks. See [the usage guide](https://slhck.info/ffmpeg-normalize/usage/file-input-output/#how-the-output-audio-codec-is-chosen) for details.
38
51
 
39
52
  - Version 1.40.0 can optionally **skip files that are already at the target level** via `--threshold` (e.g. `--threshold 0.5`, disabled by default). Such files are copied through unchanged instead of being re-encoded. The `--print-stats` output now includes a per-file `status` (`normalized`, `skipped`, or `error`, plus an `error` message on failure), and the exit code is non-zero if any file failed to process, so a script can tell what happened to each file.
@@ -138,6 +151,7 @@ The only reason this project exists in its current form is because [@benjaoming]
138
151
  <tr>
139
152
  <td align="center" valign="top" width="14.28%"><a href="https://davidbern.com/"><img src="https://avatars.githubusercontent.com/u/371066?v=4?s=100" width="100px;" alt="David Bern"/><br /><sub><b>David Bern</b></sub></a><br /><a href="https://github.com/slhck/ffmpeg-normalize/commits?author=odie5533" title="Code">💻</a></td>
140
153
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/randompersona1"><img src="https://avatars.githubusercontent.com/u/74961116?v=4?s=100" width="100px;" alt="randompersona1"/><br /><sub><b>randompersona1</b></sub></a><br /><a href="https://github.com/slhck/ffmpeg-normalize/commits?author=randompersona1" title="Code">💻</a></td>
154
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/ppetr"><img src="https://avatars.githubusercontent.com/u/163061?v=4?s=100" width="100px;" alt="Petr"/><br /><sub><b>Petr</b></sub></a><br /><a href="https://github.com/slhck/ffmpeg-normalize/commits?author=ppetr" title="Code">💻</a></td>
141
155
  </tr>
142
156
  </tbody>
143
157
  <tfoot>
@@ -0,0 +1,69 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.8.14,<1.0.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "ffmpeg-normalize"
7
+ version = "1.42.1"
8
+ description = "Normalize audio via ffmpeg"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE.md"]
12
+ keywords = [
13
+ "ffmpeg",
14
+ "normalize",
15
+ "audio",
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 5 - Production/Stable",
19
+ "Intended Audience :: Developers",
20
+ "Topic :: Multimedia :: Sound/Audio",
21
+ "Topic :: Multimedia :: Sound/Audio :: Analysis",
22
+ "Topic :: Multimedia :: Sound/Audio :: Conversion",
23
+ "Natural Language :: English",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Programming Language :: Python :: 3.14",
30
+ "Programming Language :: Python :: 3.15",
31
+ ]
32
+ requires-python = ">=3.10"
33
+ dependencies = [
34
+ "tqdm>=4.64.1",
35
+ "colorama>=0.4.6; platform_system=='Windows'",
36
+ "ffmpeg-progress-yield>=1.0.1",
37
+ "colorlog==6.7.0",
38
+ "mutagen>=1.47.0",
39
+ ]
40
+
41
+ [[project.authors]]
42
+ name = "Werner Robitza"
43
+ email = "werner.robitza@gmail.com"
44
+
45
+ [project.urls]
46
+ Homepage = "https://github.com/slhck/ffmpeg-normalize"
47
+ Repository = "https://github.com/slhck/ffmpeg-normalize"
48
+
49
+ [project.scripts]
50
+ ffmpeg-normalize = "ffmpeg_normalize.__main__:main"
51
+
52
+ [tool.uv_build]
53
+ src-layout = true
54
+
55
+ [tool.uv_build.package-data]
56
+ ffmpeg_normalize = ["data/**/*.json"]
57
+
58
+ [tool.ty.rules]
59
+ invalid-argument-type = "warn"
60
+ too-many-positional-arguments = "warn"
61
+
62
+ [dependency-groups]
63
+ dev = [
64
+ "pytest>=9.0.3",
65
+ "ruff>=0.12.11",
66
+ "types-tqdm",
67
+ "shtab>=1.7.0",
68
+ "ty>=0.0.24",
69
+ ]
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "ffmpeg-normalize"
7
- version = "1.41.1"
7
+ version = "1.42.1"
8
8
  description = "Normalize audio via ffmpeg"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -26,6 +26,7 @@ classifiers = [
26
26
  "Programming Language :: Python :: 3.12",
27
27
  "Programming Language :: Python :: 3.13",
28
28
  "Programming Language :: Python :: 3.14",
29
+ "Programming Language :: Python :: 3.15",
29
30
  ]
30
31
  requires-python = ">=3.10"
31
32
  dependencies = [
@@ -562,6 +562,23 @@ def create_parser() -> argparse.ArgumentParser:
562
562
  action="store_true",
563
563
  help="Do not write chapters to output",
564
564
  )
565
+ group_vcodec.add_argument(
566
+ "--write-encoder-settings",
567
+ action="store_true",
568
+ help=textwrap.dedent(
569
+ """\
570
+ Write the effective encoder settings as an ENCODER_SETTINGS tag on each
571
+ normalized audio stream.
572
+
573
+ The tag holds an ffmpeg-command-equivalent string with the audio codec,
574
+ the bitrate, sample rate and channel count (when set), plus the
575
+ normalization filter including the measured loudnorm values.
576
+
577
+ This is a Matroska/WebM tag convention; other containers may store it
578
+ differently. Has no effect together with `--metadata-disable`.
579
+ """
580
+ ),
581
+ )
565
582
 
566
583
  group_format = parser.add_argument_group("Input/Output options")
567
584
  group_format.add_argument(
@@ -744,6 +761,7 @@ def main() -> None:
744
761
  subtitle_disable=cli_args.subtitle_disable,
745
762
  metadata_disable=cli_args.metadata_disable,
746
763
  chapters_disable=cli_args.chapters_disable,
764
+ write_encoder_settings=cli_args.write_encoder_settings,
747
765
  extra_input_options=extra_input_options,
748
766
  extra_output_options=extra_output_options,
749
767
  output_format=cli_args.output_format,
@@ -9,7 +9,7 @@ import subprocess
9
9
  import sys
10
10
  from contextlib import contextmanager
11
11
  from shutil import which
12
- from typing import Any, Iterator
12
+ from typing import Any, Iterator, cast
13
13
 
14
14
  from ffmpeg_progress_yield import FfmpegProgress
15
15
 
@@ -161,13 +161,16 @@ class CommandRunner:
161
161
  _logger.debug("Dry mode specified, not actually running command")
162
162
  return self
163
163
 
164
- p = subprocess.Popen(
165
- cmd,
166
- stdin=subprocess.PIPE, # Apply stdin isolation by creating separate pipe.
167
- stdout=subprocess.PIPE,
168
- stderr=subprocess.PIPE,
169
- universal_newlines=False,
170
- **_get_ffmpeg_popen_kwargs(),
164
+ p = cast(
165
+ subprocess.Popen[bytes],
166
+ subprocess.Popen(
167
+ cmd,
168
+ stdin=subprocess.PIPE, # Apply stdin isolation by creating separate pipe.
169
+ stdout=subprocess.PIPE,
170
+ stderr=subprocess.PIPE,
171
+ text=False,
172
+ **_get_ffmpeg_popen_kwargs(),
173
+ ),
171
174
  )
172
175
 
173
176
  stdout_bytes, stderr_bytes = p.communicate()
@@ -82,6 +82,7 @@ class FFmpegNormalize:
82
82
  subtitle_disable (bool, optional): Disable subtitles. Defaults to False.
83
83
  metadata_disable (bool, optional): Disable metadata. Defaults to False.
84
84
  chapters_disable (bool, optional): Disable chapters. Defaults to False.
85
+ write_encoder_settings (bool, optional): Write the effective encoder settings (codec, bitrate, sample rate, channels and the normalization filter including the measured loudnorm values) as an ENCODER_SETTINGS tag on each normalized audio stream. Defaults to False.
85
86
  extra_input_options (list, optional): Extra input options. Defaults to None.
86
87
  extra_output_options (list, optional): Extra output options. Defaults to None.
87
88
  output_format (str, optional): Output format. Defaults to None.
@@ -129,6 +130,7 @@ class FFmpegNormalize:
129
130
  "subtitle_disable": False,
130
131
  "metadata_disable": False,
131
132
  "chapters_disable": False,
133
+ "write_encoder_settings": False,
132
134
  "extra_input_options": None,
133
135
  "extra_output_options": None,
134
136
  "output_format": None,
@@ -173,6 +175,7 @@ class FFmpegNormalize:
173
175
  subtitle_disable: bool = False,
174
176
  metadata_disable: bool = False,
175
177
  chapters_disable: bool = False,
178
+ write_encoder_settings: bool = False,
176
179
  extra_input_options: list[str] | None = None,
177
180
  extra_output_options: list[str] | None = None,
178
181
  output_format: str | None = None,
@@ -257,6 +260,13 @@ class FFmpegNormalize:
257
260
  self.subtitle_disable = subtitle_disable
258
261
  self.metadata_disable = metadata_disable
259
262
  self.chapters_disable = chapters_disable
263
+ self.write_encoder_settings = write_encoder_settings
264
+
265
+ if self.write_encoder_settings and self.metadata_disable:
266
+ _logger.warning(
267
+ "write_encoder_settings has no effect together with metadata_disable; "
268
+ "no metadata will be written to the output."
269
+ )
260
270
 
261
271
  self.extra_input_options = extra_input_options
262
272
  self.extra_output_options = extra_output_options
@@ -99,6 +99,10 @@ class MediaFile:
99
99
  self.streams: StreamDict = {"audio": {}, "video": {}, "subtitle": {}}
100
100
  self.temp_file: Union[str, None] = None
101
101
  self.batch_reference: float | None = None
102
+ # Second-pass filter chain per normalized audio stream, keyed by stream
103
+ # id. Populated by _get_audio_filter_cmd() and reused by
104
+ # _get_encoder_settings() so the normalization filter is not built twice.
105
+ self._stream_filter_chains: dict[int, list[str]] = {}
102
106
  # Input (access, modification) times captured before processing, used
103
107
  # when the keep_mtime option is enabled.
104
108
  self.input_timestamps: tuple[float, float] | None = None
@@ -768,6 +772,75 @@ class MediaFile:
768
772
  for _ in fun():
769
773
  pass
770
774
 
775
+ def _get_stream_filter_chain(self, audio_stream: AudioStream) -> list[str]:
776
+ """
777
+ Return the ordered list of ffmpeg audio filters applied to a single
778
+ stream in the second pass: the optional pre-filter, channel conversion,
779
+ the normalization filter itself (loudnorm for EBU, or the volume /
780
+ dynaudnorm filter for peak / RMS), and the optional post-filter.
781
+
782
+ Returns:
783
+ list[str]: the filters, in application order
784
+ """
785
+ skip_normalization = False
786
+ if self.ffmpeg_normalize.lower_only:
787
+ if self.ffmpeg_normalize.normalization_type == "ebu":
788
+ if (
789
+ audio_stream.loudness_statistics["ebu_pass1"] is not None
790
+ and audio_stream.loudness_statistics["ebu_pass1"]["input_i"]
791
+ < self.ffmpeg_normalize.target_level
792
+ ):
793
+ skip_normalization = True
794
+ elif self.ffmpeg_normalize.normalization_type == "peak":
795
+ if (
796
+ audio_stream.loudness_statistics["max"] is not None
797
+ and audio_stream.loudness_statistics["max"]
798
+ < self.ffmpeg_normalize.target_level
799
+ ):
800
+ skip_normalization = True
801
+ elif self.ffmpeg_normalize.normalization_type == "rms":
802
+ if (
803
+ audio_stream.loudness_statistics["mean"] is not None
804
+ and audio_stream.loudness_statistics["mean"]
805
+ < self.ffmpeg_normalize.target_level
806
+ ):
807
+ skip_normalization = True
808
+
809
+ if skip_normalization:
810
+ _logger.warning(
811
+ f"Stream {audio_stream.stream_id} had measured input loudness lower than target, skipping normalization."
812
+ )
813
+ normalization_filter = "acopy"
814
+ else:
815
+ if self.ffmpeg_normalize.normalization_type == "ebu":
816
+ normalization_filter = audio_stream.get_second_pass_opts_ebu(
817
+ batch_reference=self.batch_reference
818
+ )
819
+ else:
820
+ normalization_filter = audio_stream.get_second_pass_opts_peakrms(
821
+ batch_reference=self.batch_reference
822
+ )
823
+
824
+ filter_chain = []
825
+
826
+ if self.ffmpeg_normalize.pre_filter:
827
+ filter_chain.append(self.ffmpeg_normalize.pre_filter)
828
+
829
+ # Apply channel conversion before normalization so that the
830
+ # normalization filter operates on the same channel layout that
831
+ # was measured in the first pass. See issue #316.
832
+ if self.ffmpeg_normalize.audio_channels:
833
+ filter_chain.append(
834
+ f"aformat=sample_fmts=fltp:channel_layouts={self.ffmpeg_normalize.audio_channels}c"
835
+ )
836
+
837
+ filter_chain.append(normalization_filter)
838
+
839
+ if self.ffmpeg_normalize.post_filter:
840
+ filter_chain.append(self.ffmpeg_normalize.post_filter)
841
+
842
+ return filter_chain
843
+
771
844
  def _get_audio_filter_cmd(self) -> tuple[str, list[str]]:
772
845
  """
773
846
  Return the audio filter command and output labels needed.
@@ -778,76 +851,54 @@ class MediaFile:
778
851
  filter_chains = []
779
852
  output_labels = []
780
853
 
781
- streams_to_normalize = self._get_streams_to_normalize()
854
+ self._stream_filter_chains = {}
782
855
 
783
- for audio_stream in streams_to_normalize:
784
- skip_normalization = False
785
- if self.ffmpeg_normalize.lower_only:
786
- if self.ffmpeg_normalize.normalization_type == "ebu":
787
- if (
788
- audio_stream.loudness_statistics["ebu_pass1"] is not None
789
- and audio_stream.loudness_statistics["ebu_pass1"]["input_i"]
790
- < self.ffmpeg_normalize.target_level
791
- ):
792
- skip_normalization = True
793
- elif self.ffmpeg_normalize.normalization_type == "peak":
794
- if (
795
- audio_stream.loudness_statistics["max"] is not None
796
- and audio_stream.loudness_statistics["max"]
797
- < self.ffmpeg_normalize.target_level
798
- ):
799
- skip_normalization = True
800
- elif self.ffmpeg_normalize.normalization_type == "rms":
801
- if (
802
- audio_stream.loudness_statistics["mean"] is not None
803
- and audio_stream.loudness_statistics["mean"]
804
- < self.ffmpeg_normalize.target_level
805
- ):
806
- skip_normalization = True
807
-
808
- if skip_normalization:
809
- _logger.warning(
810
- f"Stream {audio_stream.stream_id} had measured input loudness lower than target, skipping normalization."
811
- )
812
- normalization_filter = "acopy"
813
- else:
814
- if self.ffmpeg_normalize.normalization_type == "ebu":
815
- normalization_filter = audio_stream.get_second_pass_opts_ebu(
816
- batch_reference=self.batch_reference
817
- )
818
- else:
819
- normalization_filter = audio_stream.get_second_pass_opts_peakrms(
820
- batch_reference=self.batch_reference
821
- )
856
+ for audio_stream in self._get_streams_to_normalize():
857
+ filter_chain = self._get_stream_filter_chain(audio_stream)
858
+ self._stream_filter_chains[audio_stream.stream_id] = filter_chain
822
859
 
823
860
  input_label = f"[0:{audio_stream.stream_id}]"
824
861
  output_label = f"[norm{audio_stream.stream_id}]"
825
862
  output_labels.append(output_label)
826
863
 
827
- filter_chain = []
828
-
829
- if self.ffmpeg_normalize.pre_filter:
830
- filter_chain.append(self.ffmpeg_normalize.pre_filter)
831
-
832
- # Apply channel conversion before normalization so that the
833
- # normalization filter operates on the same channel layout that
834
- # was measured in the first pass. See issue #316.
835
- if self.ffmpeg_normalize.audio_channels:
836
- filter_chain.append(
837
- f"aformat=sample_fmts=fltp:channel_layouts={self.ffmpeg_normalize.audio_channels}c"
838
- )
839
-
840
- filter_chain.append(normalization_filter)
841
-
842
- if self.ffmpeg_normalize.post_filter:
843
- filter_chain.append(self.ffmpeg_normalize.post_filter)
844
-
845
864
  filter_chains.append(input_label + ",".join(filter_chain) + output_label)
846
865
 
847
866
  filter_complex_cmd = ";".join(filter_chains)
848
867
 
849
868
  return filter_complex_cmd, output_labels
850
869
 
870
+ def _get_encoder_settings(
871
+ self, audio_stream: AudioStream, audio_codec: str | None
872
+ ) -> str:
873
+ """
874
+ Return an ffmpeg-command-equivalent description of how a single audio
875
+ stream is encoded in the second pass: the codec, then the bitrate,
876
+ sample rate and channel count when set, followed by the effective
877
+ filter chain (which includes the loudnorm measured values).
878
+
879
+ Written as the ``ENCODER_SETTINGS`` stream tag when
880
+ ``write_encoder_settings`` is enabled. The filter chain comes from the
881
+ cache populated by :meth:`_get_audio_filter_cmd`, which must run first.
882
+ The loudnorm ``print_format`` option is dropped, as it only controls
883
+ first-pass diagnostics and not the normalization itself.
884
+
885
+ Returns:
886
+ str: e.g. ``-c:a libopus -b:a 128000 -af loudnorm=i=-23.0:...``
887
+ """
888
+ codec = audio_codec if audio_codec else audio_stream.get_pcm_codec()
889
+ args = ["-c:a", codec]
890
+ if self.ffmpeg_normalize.audio_bitrate:
891
+ args += ["-b:a", str(self.ffmpeg_normalize.audio_bitrate)]
892
+ if self.ffmpeg_normalize.sample_rate:
893
+ args += ["-ar", str(self.ffmpeg_normalize.sample_rate)]
894
+ if self.ffmpeg_normalize.audio_channels:
895
+ args += ["-ac", str(self.ffmpeg_normalize.audio_channels)]
896
+ filter_chain = self._stream_filter_chains.get(audio_stream.stream_id, [])
897
+ if filter_chain:
898
+ af = re.sub(r":print_format=\w+", "", ",".join(filter_chain))
899
+ args += ["-af", af]
900
+ return shlex.join(args)
901
+
851
902
  def _get_audio_codec(self) -> str | None:
852
903
  """
853
904
  Return the audio encoder to use for normalized streams, or None to use
@@ -1011,6 +1062,21 @@ class MediaFile:
1011
1062
  for idx in range(len(streams_to_normalize)):
1012
1063
  cmd.extend([f"-ac:a:{idx}", str(self.ffmpeg_normalize.audio_channels)])
1013
1064
 
1065
+ # record how each normalized stream was encoded as an ENCODER_SETTINGS
1066
+ # tag (a Matroska/WebM convention), so the output documents itself
1067
+ if (
1068
+ self.ffmpeg_normalize.write_encoder_settings
1069
+ and not self.ffmpeg_normalize.metadata_disable
1070
+ ):
1071
+ for idx, audio_stream in enumerate(streams_to_normalize):
1072
+ cmd.extend(
1073
+ [
1074
+ f"-metadata:s:a:{idx}",
1075
+ "ENCODER_SETTINGS="
1076
+ + self._get_encoder_settings(audio_stream, audio_codec),
1077
+ ]
1078
+ )
1079
+
1014
1080
  # carry the input bit depth through to the output encoder, if requested
1015
1081
  if self.ffmpeg_normalize.keep_bit_depth:
1016
1082
  for idx, audio_stream in enumerate(streams_to_normalize):