From ae8ea65aa09d5a27bd607970bffd615d86ab719f Mon Sep 17 00:00:00 2001 From: Petr Date: Tue, 1 Sep 2026 15:54:33 +0200 Subject: [PATCH] feat: Add --write-encoder-settings to tag streams with encoder settings When enabled, the second-pass command writes an ENCODER_SETTINGS tag on each normalized audio stream, holding an ffmpeg-command-equivalent string with the codec, bitrate, sample rate and channels plus the normalization filter including the measured loudnorm values. It is added to the existing second-pass ffmpeg invocation, so no extra pass is performed. Has no effect together with --metadata-disable. CLI docs, shell completions and tests updated. --- README.md | 9 ++ completions/ffmpeg-normalize-shtab.bash | 3 +- completions/ffmpeg-normalize-shtab.zsh | 10 ++ completions/ffmpeg-normalize.bash | 1 + completions/ffmpeg-normalize.zsh | 1 + docs/index.md | 9 ++ docs/usage/cli-options.md | 12 ++ src/ffmpeg_normalize/__main__.py | 18 +++ src/ffmpeg_normalize/_ffmpeg_normalize.py | 10 ++ src/ffmpeg_normalize/_media_file.py | 182 +++++++++++++++------- tests/test_all.py | 61 ++++++++ 11 files changed, 257 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index da5b916..3600ea5 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ This program normalizes media files to a certain loudness level using the EBU R1 - Peak normalization — Adjust audio to a specific peak level - Selective audio stream normalization — Normalize specific audio streams or only default streams - Skip files already at target — Avoid re-encoding files already within a threshold of the target level +- Self-documenting output — Optionally record the effective encoder settings as an `ENCODER_SETTINGS` tag - Video file support — Process video files while preserving video streams - Docker support — Run via Docker container - Python API — Use programmatically in your Python projects @@ -34,6 +35,14 @@ This program normalizes media files to a certain loudness level using the EBU R1 ## 🆕 What's New +- 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`. + + Example: + + ```bash + ffmpeg-normalize input.wav -c:a libopus -b:a 128k --write-encoder-settings -o output.mkv + ``` + - 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. - 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. diff --git a/completions/ffmpeg-normalize-shtab.bash b/completions/ffmpeg-normalize-shtab.bash index 9679810..027507d 100644 --- a/completions/ffmpeg-normalize-shtab.bash +++ b/completions/ffmpeg-normalize-shtab.bash @@ -2,7 +2,7 @@ -_shtab_ffmpeg_normalize_option_strings=('-h' '--help' '--input-list' '-o' '--output' '-of' '--output-folder' '--keep-mtime' '-f' '--force' '-d' '--debug' '-v' '--verbose' '-q' '--quiet' '-n' '--dry-run' '-pr' '--progress' '--version' '--preset' '--list-presets' '-nt' '--normalization-type' '-t' '--target-level' '-p' '--print-stats' '--replaygain' '--batch' '--threshold' '-lrt' '--loudness-range-target' '--keep-loudness-range-target' '--keep-lra-above-loudness-range-target' '-tp' '--true-peak' '--offset' '--lower-only' '--auto-lower-loudness-target' '--dual-mono' '--dynamic' '-as' '--audio-streams' '--audio-default-only' '--keep-other-audio' '-c:a' '--audio-codec' '-b:a' '--audio-bitrate' '-ar' '--sample-rate' '-ac' '--audio-channels' '-koa' '--keep-original-audio' '--keep-bit-depth' '--no-keep-bit-depth' '-prf' '--pre-filter' '-pof' '--post-filter' '-vn' '--video-disable' '-c:v' '--video-codec' '-sn' '--subtitle-disable' '-mn' '--metadata-disable' '-cn' '--chapters-disable' '-ei' '--extra-input-options' '-e' '--extra-output-options' '-ofmt' '--output-format' '-ext' '--extension') +_shtab_ffmpeg_normalize_option_strings=('-h' '--help' '--input-list' '-o' '--output' '-of' '--output-folder' '--keep-mtime' '-f' '--force' '-d' '--debug' '-v' '--verbose' '-q' '--quiet' '-n' '--dry-run' '-pr' '--progress' '--version' '--preset' '--list-presets' '-nt' '--normalization-type' '-t' '--target-level' '-p' '--print-stats' '--replaygain' '--batch' '--threshold' '-lrt' '--loudness-range-target' '--keep-loudness-range-target' '--keep-lra-above-loudness-range-target' '-tp' '--true-peak' '--offset' '--lower-only' '--auto-lower-loudness-target' '--dual-mono' '--dynamic' '-as' '--audio-streams' '--audio-default-only' '--keep-other-audio' '-c:a' '--audio-codec' '-b:a' '--audio-bitrate' '-ar' '--sample-rate' '-ac' '--audio-channels' '-koa' '--keep-original-audio' '--keep-bit-depth' '--no-keep-bit-depth' '-prf' '--pre-filter' '-pof' '--post-filter' '-vn' '--video-disable' '-c:v' '--video-codec' '-sn' '--subtitle-disable' '-mn' '--metadata-disable' '-cn' '--chapters-disable' '--write-encoder-settings' '-ei' '--extra-input-options' '-e' '--extra-output-options' '-ofmt' '--output-format' '-ext' '--extension') @@ -53,6 +53,7 @@ _shtab_ffmpeg_normalize__mn_nargs=0 _shtab_ffmpeg_normalize___metadata_disable_nargs=0 _shtab_ffmpeg_normalize__cn_nargs=0 _shtab_ffmpeg_normalize___chapters_disable_nargs=0 +_shtab_ffmpeg_normalize___write_encoder_settings_nargs=0 # $1=COMP_WORDS[1] diff --git a/completions/ffmpeg-normalize-shtab.zsh b/completions/ffmpeg-normalize-shtab.zsh index 143e959..e08c9f8 100644 --- a/completions/ffmpeg-normalize-shtab.zsh +++ b/completions/ffmpeg-normalize-shtab.zsh @@ -203,6 +203,16 @@ Will attempt to copy video codec by default. {-sn,--subtitle-disable}"[Do not write subtitle streams to output]" {-mn,--metadata-disable}"[Do not write metadata to output]" {-cn,--chapters-disable}"[Do not write chapters to output]" + "--write-encoder-settings[Write the effective encoder settings as an ENCODER_SETTINGS tag on each +normalized audio stream. + +The tag holds an ffmpeg-command-equivalent string with the audio codec, +the bitrate, sample rate and channel count (when set), plus the +normalization filter including the measured loudnorm values. + +This is a Matroska\/WebM tag convention\; other containers may store it +differently. Has no effect together with \`--metadata-disable\`. +]" {-ei,--extra-input-options}"[Extra input options list. A list of extra ffmpeg command line arguments valid for the input, diff --git a/completions/ffmpeg-normalize.bash b/completions/ffmpeg-normalize.bash index 08a4c23..b054b3c 100644 --- a/completions/ffmpeg-normalize.bash +++ b/completions/ffmpeg-normalize.bash @@ -47,6 +47,7 @@ _ffmpeg_normalize() -sn --subtitle-disable \ -mn --metadata-disable \ -cn --chapters-disable \ + --write-encoder-settings \ -ei --extra-input-options \ -e --extra-output-options \ -ofmt --output-format \ diff --git a/completions/ffmpeg-normalize.zsh b/completions/ffmpeg-normalize.zsh index 6ae85b9..ef37b69 100644 --- a/completions/ffmpeg-normalize.zsh +++ b/completions/ffmpeg-normalize.zsh @@ -57,6 +57,7 @@ _ffmpeg_normalize() { '(-sn --subtitle-disable)'{-sn,--subtitle-disable}'[Disable subtitles]' '(-mn --metadata-disable)'{-mn,--metadata-disable}'[Disable metadata]' '(-cn --chapters-disable)'{-cn,--chapters-disable}'[Disable chapters]' + '--write-encoder-settings[Write effective encoder settings as an ENCODER_SETTINGS stream tag]' # Format Options '(-ei --extra-input-options)'{-ei,--extra-input-options}'[Extra input options]:options:' diff --git a/docs/index.md b/docs/index.md index 04b08b4..609ffd9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,6 +25,7 @@ Batch processing of several input files is possible, including video files. - **Selective audio stream normalization** — Normalize specific audio streams or only default streams - **Skip files already at target** — Avoid re-encoding files already within a threshold of the target level - **Per-file outcome reporting** — `status` field in `--print-stats` plus exit codes for scripting +- **Self-documenting output** — Optionally record the effective encoder settings as an `ENCODER_SETTINGS` tag - **Video file support** — Process video files while preserving video streams - **Docker support** — Run via Docker container - **Python API** — Use programmatically in your Python projects @@ -33,6 +34,14 @@ Batch processing of several input files is possible, including video files. ## 🆕 What's New +- 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`. + + Example: + + ```bash + ffmpeg-normalize input.wav -c:a libopus -b:a 128k --write-encoder-settings -o output.mkv + ``` + - 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. - 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. diff --git a/docs/usage/cli-options.md b/docs/usage/cli-options.md index b29327c..d60401b 100644 --- a/docs/usage/cli-options.md +++ b/docs/usage/cli-options.md @@ -316,6 +316,18 @@ Do not write metadata to output Do not write chapters to output +### `--write-encoder-settings` + +Write the effective encoder settings as an `ENCODER_SETTINGS` tag on each normalized audio stream. + +The tag holds an ffmpeg-command-equivalent string with the audio codec, the bitrate, sample rate and channel count (when set), plus the normalization filter including the measured loudnorm values, for example: + +``` +-c:a libopus -b:a 128000 -af loudnorm=i=-23.0:lra=7.0:tp=-2.0:offset=-0.1:measured_i=-19.4:measured_lra=6.1:measured_tp=-3.2:measured_thresh=-29.6:linear=true +``` + +This is a Matroska/WebM tag convention; other containers may store it differently. It has no effect together with `--metadata-disable`. + ## Input/Output Format ### `-ei EXTRA_INPUT_OPTIONS, --extra-input-options EXTRA_INPUT_OPTIONS` diff --git a/src/ffmpeg_normalize/__main__.py b/src/ffmpeg_normalize/__main__.py index cc0901d..9b65ef9 100644 --- a/src/ffmpeg_normalize/__main__.py +++ b/src/ffmpeg_normalize/__main__.py @@ -562,6 +562,23 @@ def create_parser() -> argparse.ArgumentParser: action="store_true", help="Do not write chapters to output", ) + group_vcodec.add_argument( + "--write-encoder-settings", + action="store_true", + help=textwrap.dedent( + """\ + Write the effective encoder settings as an ENCODER_SETTINGS tag on each + normalized audio stream. + + The tag holds an ffmpeg-command-equivalent string with the audio codec, + the bitrate, sample rate and channel count (when set), plus the + normalization filter including the measured loudnorm values. + + This is a Matroska/WebM tag convention; other containers may store it + differently. Has no effect together with `--metadata-disable`. + """ + ), + ) group_format = parser.add_argument_group("Input/Output options") group_format.add_argument( @@ -744,6 +761,7 @@ def _split_options(opts: str) -> list[str]: subtitle_disable=cli_args.subtitle_disable, metadata_disable=cli_args.metadata_disable, chapters_disable=cli_args.chapters_disable, + write_encoder_settings=cli_args.write_encoder_settings, extra_input_options=extra_input_options, extra_output_options=extra_output_options, output_format=cli_args.output_format, diff --git a/src/ffmpeg_normalize/_ffmpeg_normalize.py b/src/ffmpeg_normalize/_ffmpeg_normalize.py index d6d2e09..92551dd 100644 --- a/src/ffmpeg_normalize/_ffmpeg_normalize.py +++ b/src/ffmpeg_normalize/_ffmpeg_normalize.py @@ -82,6 +82,7 @@ class FFmpegNormalize: subtitle_disable (bool, optional): Disable subtitles. Defaults to False. metadata_disable (bool, optional): Disable metadata. Defaults to False. chapters_disable (bool, optional): Disable chapters. Defaults to False. + 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. extra_input_options (list, optional): Extra input options. Defaults to None. extra_output_options (list, optional): Extra output options. Defaults to None. output_format (str, optional): Output format. Defaults to None. @@ -129,6 +130,7 @@ class FFmpegNormalize: "subtitle_disable": False, "metadata_disable": False, "chapters_disable": False, + "write_encoder_settings": False, "extra_input_options": None, "extra_output_options": None, "output_format": None, @@ -173,6 +175,7 @@ def __init__( subtitle_disable: bool = False, metadata_disable: bool = False, chapters_disable: bool = False, + write_encoder_settings: bool = False, extra_input_options: list[str] | None = None, extra_output_options: list[str] | None = None, output_format: str | None = None, @@ -257,6 +260,13 @@ def __init__( self.subtitle_disable = subtitle_disable self.metadata_disable = metadata_disable self.chapters_disable = chapters_disable + self.write_encoder_settings = write_encoder_settings + + if self.write_encoder_settings and self.metadata_disable: + _logger.warning( + "write_encoder_settings has no effect together with metadata_disable; " + "no metadata will be written to the output." + ) self.extra_input_options = extra_input_options self.extra_output_options = extra_output_options diff --git a/src/ffmpeg_normalize/_media_file.py b/src/ffmpeg_normalize/_media_file.py index 72fcea9..7069f92 100644 --- a/src/ffmpeg_normalize/_media_file.py +++ b/src/ffmpeg_normalize/_media_file.py @@ -99,6 +99,10 @@ def __init__( self.streams: StreamDict = {"audio": {}, "video": {}, "subtitle": {}} self.temp_file: Union[str, None] = None self.batch_reference: float | None = None + # Second-pass filter chain per normalized audio stream, keyed by stream + # id. Populated by _get_audio_filter_cmd() and reused by + # _get_encoder_settings() so the normalization filter is not built twice. + self._stream_filter_chains: dict[int, list[str]] = {} # Input (access, modification) times captured before processing, used # when the keep_mtime option is enabled. self.input_timestamps: tuple[float, float] | None = None @@ -768,6 +772,75 @@ def _first_pass(self) -> None: for _ in fun(): pass + def _get_stream_filter_chain(self, audio_stream: AudioStream) -> list[str]: + """ + Return the ordered list of ffmpeg audio filters applied to a single + stream in the second pass: the optional pre-filter, channel conversion, + the normalization filter itself (loudnorm for EBU, or the volume / + dynaudnorm filter for peak / RMS), and the optional post-filter. + + Returns: + list[str]: the filters, in application order + """ + skip_normalization = False + if self.ffmpeg_normalize.lower_only: + if self.ffmpeg_normalize.normalization_type == "ebu": + if ( + audio_stream.loudness_statistics["ebu_pass1"] is not None + and audio_stream.loudness_statistics["ebu_pass1"]["input_i"] + < self.ffmpeg_normalize.target_level + ): + skip_normalization = True + elif self.ffmpeg_normalize.normalization_type == "peak": + if ( + audio_stream.loudness_statistics["max"] is not None + and audio_stream.loudness_statistics["max"] + < self.ffmpeg_normalize.target_level + ): + skip_normalization = True + elif self.ffmpeg_normalize.normalization_type == "rms": + if ( + audio_stream.loudness_statistics["mean"] is not None + and audio_stream.loudness_statistics["mean"] + < self.ffmpeg_normalize.target_level + ): + skip_normalization = True + + if skip_normalization: + _logger.warning( + f"Stream {audio_stream.stream_id} had measured input loudness lower than target, skipping normalization." + ) + normalization_filter = "acopy" + else: + if self.ffmpeg_normalize.normalization_type == "ebu": + normalization_filter = audio_stream.get_second_pass_opts_ebu( + batch_reference=self.batch_reference + ) + else: + normalization_filter = audio_stream.get_second_pass_opts_peakrms( + batch_reference=self.batch_reference + ) + + filter_chain = [] + + if self.ffmpeg_normalize.pre_filter: + filter_chain.append(self.ffmpeg_normalize.pre_filter) + + # Apply channel conversion before normalization so that the + # normalization filter operates on the same channel layout that + # was measured in the first pass. See issue #316. + if self.ffmpeg_normalize.audio_channels: + filter_chain.append( + f"aformat=sample_fmts=fltp:channel_layouts={self.ffmpeg_normalize.audio_channels}c" + ) + + filter_chain.append(normalization_filter) + + if self.ffmpeg_normalize.post_filter: + filter_chain.append(self.ffmpeg_normalize.post_filter) + + return filter_chain + def _get_audio_filter_cmd(self) -> tuple[str, list[str]]: """ Return the audio filter command and output labels needed. @@ -778,76 +851,54 @@ def _get_audio_filter_cmd(self) -> tuple[str, list[str]]: filter_chains = [] output_labels = [] - streams_to_normalize = self._get_streams_to_normalize() + self._stream_filter_chains = {} - for audio_stream in streams_to_normalize: - skip_normalization = False - if self.ffmpeg_normalize.lower_only: - if self.ffmpeg_normalize.normalization_type == "ebu": - if ( - audio_stream.loudness_statistics["ebu_pass1"] is not None - and audio_stream.loudness_statistics["ebu_pass1"]["input_i"] - < self.ffmpeg_normalize.target_level - ): - skip_normalization = True - elif self.ffmpeg_normalize.normalization_type == "peak": - if ( - audio_stream.loudness_statistics["max"] is not None - and audio_stream.loudness_statistics["max"] - < self.ffmpeg_normalize.target_level - ): - skip_normalization = True - elif self.ffmpeg_normalize.normalization_type == "rms": - if ( - audio_stream.loudness_statistics["mean"] is not None - and audio_stream.loudness_statistics["mean"] - < self.ffmpeg_normalize.target_level - ): - skip_normalization = True - - if skip_normalization: - _logger.warning( - f"Stream {audio_stream.stream_id} had measured input loudness lower than target, skipping normalization." - ) - normalization_filter = "acopy" - else: - if self.ffmpeg_normalize.normalization_type == "ebu": - normalization_filter = audio_stream.get_second_pass_opts_ebu( - batch_reference=self.batch_reference - ) - else: - normalization_filter = audio_stream.get_second_pass_opts_peakrms( - batch_reference=self.batch_reference - ) + for audio_stream in self._get_streams_to_normalize(): + filter_chain = self._get_stream_filter_chain(audio_stream) + self._stream_filter_chains[audio_stream.stream_id] = filter_chain input_label = f"[0:{audio_stream.stream_id}]" output_label = f"[norm{audio_stream.stream_id}]" output_labels.append(output_label) - filter_chain = [] - - if self.ffmpeg_normalize.pre_filter: - filter_chain.append(self.ffmpeg_normalize.pre_filter) - - # Apply channel conversion before normalization so that the - # normalization filter operates on the same channel layout that - # was measured in the first pass. See issue #316. - if self.ffmpeg_normalize.audio_channels: - filter_chain.append( - f"aformat=sample_fmts=fltp:channel_layouts={self.ffmpeg_normalize.audio_channels}c" - ) - - filter_chain.append(normalization_filter) - - if self.ffmpeg_normalize.post_filter: - filter_chain.append(self.ffmpeg_normalize.post_filter) - filter_chains.append(input_label + ",".join(filter_chain) + output_label) filter_complex_cmd = ";".join(filter_chains) return filter_complex_cmd, output_labels + def _get_encoder_settings( + self, audio_stream: AudioStream, audio_codec: str | None + ) -> str: + """ + Return an ffmpeg-command-equivalent description of how a single audio + stream is encoded in the second pass: the codec, then the bitrate, + sample rate and channel count when set, followed by the effective + filter chain (which includes the loudnorm measured values). + + Written as the ``ENCODER_SETTINGS`` stream tag when + ``write_encoder_settings`` is enabled. The filter chain comes from the + cache populated by :meth:`_get_audio_filter_cmd`, which must run first. + The loudnorm ``print_format`` option is dropped, as it only controls + first-pass diagnostics and not the normalization itself. + + Returns: + str: e.g. ``-c:a libopus -b:a 128000 -af loudnorm=i=-23.0:...`` + """ + codec = audio_codec if audio_codec else audio_stream.get_pcm_codec() + args = ["-c:a", codec] + if self.ffmpeg_normalize.audio_bitrate: + args += ["-b:a", str(self.ffmpeg_normalize.audio_bitrate)] + if self.ffmpeg_normalize.sample_rate: + args += ["-ar", str(self.ffmpeg_normalize.sample_rate)] + if self.ffmpeg_normalize.audio_channels: + args += ["-ac", str(self.ffmpeg_normalize.audio_channels)] + filter_chain = self._stream_filter_chains.get(audio_stream.stream_id, []) + if filter_chain: + af = re.sub(r":print_format=\w+", "", ",".join(filter_chain)) + args += ["-af", af] + return shlex.join(args) + def _get_audio_codec(self) -> str | None: """ Return the audio encoder to use for normalized streams, or None to use @@ -1011,6 +1062,21 @@ def _second_pass(self) -> Iterator[float]: for idx in range(len(streams_to_normalize)): cmd.extend([f"-ac:a:{idx}", str(self.ffmpeg_normalize.audio_channels)]) + # record how each normalized stream was encoded as an ENCODER_SETTINGS + # tag (a Matroska/WebM convention), so the output documents itself + if ( + self.ffmpeg_normalize.write_encoder_settings + and not self.ffmpeg_normalize.metadata_disable + ): + for idx, audio_stream in enumerate(streams_to_normalize): + cmd.extend( + [ + f"-metadata:s:a:{idx}", + "ENCODER_SETTINGS=" + + self._get_encoder_settings(audio_stream, audio_codec), + ] + ) + # carry the input bit depth through to the output encoder, if requested if self.ffmpeg_normalize.keep_bit_depth: for idx, audio_stream in enumerate(streams_to_normalize): diff --git a/tests/test_all.py b/tests/test_all.py index db70e19..cec86ee 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -988,6 +988,67 @@ def test_keep_other_and_keep_original_conflict(self): ) assert "Cannot use both" in stderr + def test_write_encoder_settings_ebu(self): + ffmpeg_normalize_call( + [ + "tests/test.mp3", + "-o", + "normalized/test.mkv", + "-c:a", + "aac", + "-b:a", + "128k", + "--write-encoder-settings", + ] + ) + assert os.path.isfile("normalized/test.mkv") + tags = _get_stream_info("normalized/test.mkv")[0]["tags"] + settings = tags.get("ENCODER_SETTINGS") or tags.get("encoder_settings") + assert settings is not None + assert settings.startswith("-c:a aac -b:a 128k") + assert "-af loudnorm=" in settings + assert "measured_i=" in settings + # the diagnostic-only print_format option is stripped + assert "print_format" not in settings + + def test_write_encoder_settings_peak(self): + ffmpeg_normalize_call( + [ + "tests/test.mp3", + "-o", + "normalized/test.mkv", + "-nt", + "peak", + "-t", + "-5", + "--write-encoder-settings", + ] + ) + assert os.path.isfile("normalized/test.mkv") + tags = _get_stream_info("normalized/test.mkv")[0]["tags"] + settings = tags.get("ENCODER_SETTINGS") or tags.get("encoder_settings") + assert settings is not None + assert settings.startswith("-c:a ") + assert "-af volume=" in settings + + def test_write_encoder_settings_metadata_disable(self): + _, stderr = ffmpeg_normalize_call( + [ + "tests/test.mp3", + "-o", + "normalized/test.mkv", + "-c:a", + "aac", + "--write-encoder-settings", + "-mn", + ] + ) + assert os.path.isfile("normalized/test.mkv") + assert "no effect" in stderr + tags = _get_stream_info("normalized/test.mkv")[0].get("tags", {}) + assert "ENCODER_SETTINGS" not in tags + assert "encoder_settings" not in tags + class TestFileValidation: """Tests for pre-batch file validation."""