ffmpeg-normalize 1.35.0__tar.gz → 1.36.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ffmpeg-normalize
3
- Version: 1.35.0
3
+ Version: 1.36.0
4
4
  Summary: Normalize audio via ffmpeg
5
5
  Keywords: ffmpeg,normalize,audio
6
6
  Author: Werner Robitza
@@ -58,6 +58,16 @@ This program normalizes media files to a certain loudness level using the EBU R1
58
58
 
59
59
  ## 🆕 What's New
60
60
 
61
+ - Version 1.36.0 introduces **presets** with `--preset`! Save and reuse your favorite normalization configurations for different use cases. Comes with three built-in presets: `podcast` (AES standard), `music` (RMS-based batch normalization), and `streaming-video` (video content). Create custom presets too!
62
+
63
+ Example:
64
+
65
+ ```bash
66
+ ffmpeg-normalize input.mp3 --preset podcast
67
+ ```
68
+
69
+ applies the podcast preset (EBU R128, -16 LUFS) to your file. Learn more in the [presets guide](https://slhck.info/ffmpeg-normalize/usage/presets/).
70
+
61
71
  - 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
72
 
63
73
  Example:
@@ -26,6 +26,16 @@ This program normalizes media files to a certain loudness level using the EBU R1
26
26
 
27
27
  ## 🆕 What's New
28
28
 
29
+ - Version 1.36.0 introduces **presets** with `--preset`! Save and reuse your favorite normalization configurations for different use cases. Comes with three built-in presets: `podcast` (AES standard), `music` (RMS-based batch normalization), and `streaming-video` (video content). Create custom presets too!
30
+
31
+ Example:
32
+
33
+ ```bash
34
+ ffmpeg-normalize input.mp3 --preset podcast
35
+ ```
36
+
37
+ applies the podcast preset (EBU R128, -16 LUFS) to your file. Learn more in the [presets guide](https://slhck.info/ffmpeg-normalize/usage/presets/).
38
+
29
39
  - 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.
30
40
 
31
41
  Example:
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "ffmpeg-normalize"
7
- version = "1.35.0"
7
+ version = "1.36.0"
8
8
  description = "Normalize audio via ffmpeg"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -46,6 +46,7 @@ ffmpeg-normalize = "ffmpeg_normalize.__main__:main"
46
46
 
47
47
  [tool.uv_build]
48
48
  src-layout = true
49
+ package-data = {"ffmpeg_normalize" = ["data/**/*.json"]}
49
50
 
50
51
  [dependency-groups]
51
52
  dev = [
@@ -13,6 +13,7 @@ from typing import NoReturn
13
13
  from ._errors import FFmpegNormalizeError
14
14
  from ._ffmpeg_normalize import NORMALIZATION_TYPES, FFmpegNormalize
15
15
  from ._logger import setup_cli_logger
16
+ from ._presets import PresetManager
16
17
 
17
18
  # Import version from package
18
19
  import importlib.metadata
@@ -50,7 +51,7 @@ def create_parser() -> argparse.ArgumentParser:
50
51
 
51
52
  Author: Werner Robitza
52
53
  License: MIT
53
- Homepage / Issues: https://github.com/slhck/ffmpeg-normalize
54
+ Website / Issues: https://github.com/slhck/ffmpeg-normalize
54
55
  """
55
56
  ),
56
57
  )
@@ -90,7 +91,7 @@ def create_parser() -> argparse.ArgumentParser:
90
91
  name specified.
91
92
  """
92
93
  ),
93
- default="normalized",
94
+ default=FFmpegNormalize.DEFAULTS["output_folder"],
94
95
  )
95
96
 
96
97
  group_general = parser.add_argument_group("General Options")
@@ -124,6 +125,28 @@ def create_parser() -> argparse.ArgumentParser:
124
125
  version=f"%(prog)s v{__version__}",
125
126
  help="Print version and exit",
126
127
  )
128
+ group_general.add_argument(
129
+ "--preset",
130
+ type=str,
131
+ help=textwrap.dedent(
132
+ """\
133
+ Load options from a preset file.
134
+
135
+ Preset files are JSON files located in the presets directory.
136
+ The directory location depends on your OS:
137
+ - Linux/macOS: ~/.config/ffmpeg-normalize/presets/
138
+ - Windows: %%APPDATA%%/ffmpeg-normalize/presets/
139
+
140
+ Use --list-presets to see available presets.
141
+ CLI options specified on the command line take precedence over preset values.
142
+ """
143
+ ),
144
+ )
145
+ group_general.add_argument(
146
+ "--list-presets",
147
+ action="store_true",
148
+ help="List all available presets and exit",
149
+ )
127
150
 
128
151
  group_normalization = parser.add_argument_group("Normalization")
129
152
  group_normalization.add_argument(
@@ -144,15 +167,15 @@ def create_parser() -> argparse.ArgumentParser:
144
167
  Peak normalization brings the signal to the specified peak level.
145
168
  """
146
169
  ),
147
- default="ebu",
170
+ default=FFmpegNormalize.DEFAULTS["normalization_type"],
148
171
  )
149
172
  group_normalization.add_argument(
150
173
  "-t",
151
174
  "--target-level",
152
175
  type=float,
153
176
  help=textwrap.dedent(
154
- """\
155
- Normalization target level in dB/LUFS (default: -23).
177
+ f"""\
178
+ Normalization target level in dB/LUFS (default: {FFmpegNormalize.DEFAULTS["target_level"]}).
156
179
 
157
180
  For EBU normalization, it corresponds to Integrated Loudness Target
158
181
  in LUFS. The range is -70.0 - -5.0.
@@ -160,7 +183,7 @@ def create_parser() -> argparse.ArgumentParser:
160
183
  Otherwise, the range is -99 to 0.
161
184
  """
162
185
  ),
163
- default=-23.0,
186
+ default=FFmpegNormalize.DEFAULTS["target_level"],
164
187
  )
165
188
  group_normalization.add_argument(
166
189
  "-p",
@@ -215,12 +238,12 @@ def create_parser() -> argparse.ArgumentParser:
215
238
  "--loudness-range-target",
216
239
  type=float,
217
240
  help=textwrap.dedent(
218
- """\
219
- EBU Loudness Range Target in LUFS (default: 7.0).
241
+ f"""\
242
+ EBU Loudness Range Target in LUFS (default: {FFmpegNormalize.DEFAULTS["loudness_range_target"]}).
220
243
  Range is 1.0 - 50.0.
221
244
  """
222
245
  ),
223
- default=7.0,
246
+ default=FFmpegNormalize.DEFAULTS["loudness_range_target"],
224
247
  )
225
248
 
226
249
  group_ebu.add_argument(
@@ -249,26 +272,26 @@ def create_parser() -> argparse.ArgumentParser:
249
272
  "--true-peak",
250
273
  type=float,
251
274
  help=textwrap.dedent(
252
- """\
253
- EBU Maximum True Peak in dBTP (default: -2.0).
275
+ f"""\
276
+ EBU Maximum True Peak in dBTP (default: {FFmpegNormalize.DEFAULTS["true_peak"]}).
254
277
  Range is -9.0 - +0.0.
255
278
  """
256
279
  ),
257
- default=-2.0,
280
+ default=FFmpegNormalize.DEFAULTS["true_peak"],
258
281
  )
259
282
 
260
283
  group_ebu.add_argument(
261
284
  "--offset",
262
285
  type=float,
263
286
  help=textwrap.dedent(
264
- """\
265
- EBU Offset Gain (default: 0.0).
287
+ f"""\
288
+ EBU Offset Gain (default: {FFmpegNormalize.DEFAULTS["offset"]}).
266
289
  The gain is applied before the true-peak limiter in the first pass only.
267
290
  The offset for the second pass will be automatically determined based on the first pass statistics.
268
291
  Range is -99.0 - +99.0.
269
292
  """
270
293
  ),
271
- default=0.0,
294
+ default=FFmpegNormalize.DEFAULTS["offset"],
272
295
  )
273
296
 
274
297
  group_ebu.add_argument(
@@ -471,7 +494,7 @@ def create_parser() -> argparse.ArgumentParser:
471
494
  Will attempt to copy video codec by default.
472
495
  """
473
496
  ),
474
- default="copy",
497
+ default=FFmpegNormalize.DEFAULTS["video_codec"],
475
498
  )
476
499
  group_vcodec.add_argument(
477
500
  "-sn",
@@ -565,7 +588,7 @@ def create_parser() -> argparse.ArgumentParser:
565
588
  specified. (Default: `mkv`)
566
589
  """
567
590
  ),
568
- default="mkv",
591
+ default=FFmpegNormalize.DEFAULTS["extension"],
569
592
  )
570
593
  return parser
571
594
 
@@ -581,6 +604,28 @@ def main() -> None:
581
604
  _logger.error(message)
582
605
  sys.exit(1)
583
606
 
607
+ # Handle --list-presets
608
+ preset_manager = PresetManager()
609
+ if cli_args.list_presets:
610
+ presets = preset_manager.get_available_presets()
611
+ if presets:
612
+ print("Available presets:")
613
+ for preset in presets:
614
+ print(f" - {preset}")
615
+ else:
616
+ print(f"No presets found in {preset_manager.presets_dir}")
617
+ sys.exit(0)
618
+
619
+ # Load and apply preset if specified
620
+ if cli_args.preset:
621
+ try:
622
+ preset_data = preset_manager.load_preset(cli_args.preset)
623
+ _logger.debug(f"Loaded preset '{cli_args.preset}': {preset_data}")
624
+ preset_manager.merge_preset_with_args(preset_data, cli_args)
625
+ _logger.info(f"Applied preset '{cli_args.preset}'")
626
+ except (FileNotFoundError, json.JSONDecodeError, ValueError) as e:
627
+ error(str(e))
628
+
584
629
  def _split_options(opts: str) -> list[str]:
585
630
  """
586
631
  Parse extra options (input or output) into a list.
@@ -93,6 +93,48 @@ class FFmpegNormalize:
93
93
  FFmpegNormalizeError: If the ffmpeg executable is not found or does not support the loudnorm filter.
94
94
  """
95
95
 
96
+ # Default parameter values - single source of truth for all defaults
97
+ # Note: output_folder is a CLI-level option and not passed to FFmpegNormalize.__init__
98
+ DEFAULTS = {
99
+ "normalization_type": "ebu",
100
+ "target_level": -23.0,
101
+ "print_stats": False,
102
+ "loudness_range_target": 7.0,
103
+ "keep_loudness_range_target": False,
104
+ "keep_lra_above_loudness_range_target": False,
105
+ "true_peak": -2.0,
106
+ "offset": 0.0,
107
+ "lower_only": False,
108
+ "auto_lower_loudness_target": False,
109
+ "dual_mono": False,
110
+ "dynamic": False,
111
+ "audio_codec": "pcm_s16le",
112
+ "audio_bitrate": None,
113
+ "sample_rate": None,
114
+ "audio_channels": None,
115
+ "keep_original_audio": False,
116
+ "pre_filter": None,
117
+ "post_filter": None,
118
+ "video_codec": "copy",
119
+ "video_disable": False,
120
+ "subtitle_disable": False,
121
+ "metadata_disable": False,
122
+ "chapters_disable": False,
123
+ "extra_input_options": None,
124
+ "extra_output_options": None,
125
+ "output_format": None,
126
+ "output_folder": "normalized",
127
+ "extension": "mkv",
128
+ "dry_run": False,
129
+ "debug": False,
130
+ "progress": False,
131
+ "replaygain": False,
132
+ "batch": False,
133
+ "audio_streams": None,
134
+ "audio_default_only": False,
135
+ "keep_other_audio": False,
136
+ }
137
+
96
138
  def __init__(
97
139
  self,
98
140
  normalization_type: Literal["ebu", "rms", "peak"] = "ebu",
@@ -0,0 +1,256 @@
1
+ """Preset management for ffmpeg-normalize."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import shutil
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from ._ffmpeg_normalize import FFmpegNormalize
13
+
14
+ _logger = logging.getLogger(__name__)
15
+
16
+
17
+ def get_config_dir() -> Path:
18
+ """Get the platform-specific config directory for ffmpeg-normalize.
19
+
20
+ Returns:
21
+ Path: Configuration directory for presets
22
+
23
+ On Linux/macOS:
24
+ - XDG_CONFIG_HOME/ffmpeg-normalize (if XDG_CONFIG_HOME is set)
25
+ - ~/.config/ffmpeg-normalize (otherwise)
26
+
27
+ On Windows:
28
+ - %APPDATA%/ffmpeg-normalize
29
+ """
30
+ if os.name == "nt": # Windows
31
+ appdata = os.getenv("APPDATA")
32
+ if appdata:
33
+ config_dir = Path(appdata) / "ffmpeg-normalize"
34
+ else:
35
+ config_dir = Path.home() / "AppData" / "Roaming" / "ffmpeg-normalize"
36
+ else: # Linux/macOS
37
+ xdg_config = os.getenv("XDG_CONFIG_HOME")
38
+ if xdg_config:
39
+ config_dir = Path(xdg_config) / "ffmpeg-normalize"
40
+ else:
41
+ config_dir = Path.home() / ".config" / "ffmpeg-normalize"
42
+
43
+ return config_dir
44
+
45
+
46
+ def get_presets_dir() -> Path:
47
+ """Get the presets directory.
48
+
49
+ Returns:
50
+ Path: Directory containing preset files
51
+ """
52
+ return get_config_dir() / "presets"
53
+
54
+
55
+ def get_default_presets_dir() -> Path:
56
+ """Get the package default presets directory.
57
+
58
+ Returns:
59
+ Path: Directory containing default preset files bundled with the package
60
+ """
61
+ # Get the directory where this module is located
62
+ module_dir = Path(__file__).parent
63
+ return module_dir / "data" / "presets"
64
+
65
+
66
+ class PresetManager:
67
+ """Manages loading and merging of presets with CLI arguments."""
68
+
69
+ def __init__(self) -> None:
70
+ """Initialize the preset manager."""
71
+ self.presets_dir = get_presets_dir()
72
+ self.default_presets_dir = get_default_presets_dir()
73
+
74
+ def get_available_presets(self) -> list[str]:
75
+ """Get list of available preset names.
76
+
77
+ Includes both user-installed presets and default presets from the package.
78
+ User presets take precedence if they have the same name.
79
+
80
+ Returns:
81
+ list[str]: List of available preset names (without .json extension)
82
+ """
83
+ presets = set()
84
+
85
+ # Get presets from user config directory
86
+ if self.presets_dir.exists():
87
+ for file in self.presets_dir.glob("*.json"):
88
+ presets.add(file.stem)
89
+
90
+ # Get default presets from package
91
+ if self.default_presets_dir.exists():
92
+ for file in self.default_presets_dir.glob("*.json"):
93
+ presets.add(file.stem)
94
+
95
+ return sorted(presets)
96
+
97
+ def load_preset(self, preset_name: str) -> dict[str, Any]:
98
+ """Load a preset file by name.
99
+
100
+ Checks user config directory first, then falls back to package defaults.
101
+
102
+ Args:
103
+ preset_name: Name of the preset (without .json extension)
104
+
105
+ Returns:
106
+ dict[str, Any]: Preset configuration as a dictionary
107
+
108
+ Raises:
109
+ FileNotFoundError: If preset file doesn't exist
110
+ json.JSONDecodeError: If preset file is invalid JSON
111
+ """
112
+ # Try user config directory first
113
+ preset_path = self.presets_dir / f"{preset_name}.json"
114
+
115
+ # Fall back to package default presets
116
+ if not preset_path.exists():
117
+ preset_path = self.default_presets_dir / f"{preset_name}.json"
118
+
119
+ if not preset_path.exists():
120
+ raise FileNotFoundError(
121
+ f"Preset '{preset_name}' not found. "
122
+ f"Available presets: {', '.join(self.get_available_presets()) or 'none'}"
123
+ )
124
+
125
+ try:
126
+ with open(preset_path, "r") as f:
127
+ preset_data = json.load(f)
128
+ except json.JSONDecodeError as e:
129
+ raise json.JSONDecodeError(
130
+ f"Invalid JSON in preset '{preset_name}': {e.msg}",
131
+ e.doc,
132
+ e.pos,
133
+ )
134
+
135
+ if not isinstance(preset_data, dict):
136
+ raise ValueError(
137
+ f"Preset must be a JSON object, got {type(preset_data).__name__}"
138
+ )
139
+
140
+ return preset_data
141
+
142
+ def merge_preset_with_args(
143
+ self, preset_data: dict[str, Any], cli_args: Any
144
+ ) -> None:
145
+ """Merge preset data with CLI arguments, giving precedence to CLI args.
146
+
147
+ Args:
148
+ preset_data: Dictionary of preset configuration
149
+ cli_args: Parsed CLI arguments (argparse Namespace object)
150
+
151
+ The CLI arguments take precedence over preset values. This function modifies
152
+ cli_args in place by setting attributes that were not explicitly provided
153
+ on the command line.
154
+ """
155
+ for key, value in preset_data.items():
156
+ # Convert hyphens to underscores to match argparse attribute names
157
+ attr_name = key.replace("-", "_")
158
+
159
+ # Check if this attribute exists in cli_args
160
+ if not hasattr(cli_args, attr_name):
161
+ _logger.warning(
162
+ f"Preset option '{key}' is not a valid ffmpeg-normalize option. Skipping."
163
+ )
164
+ continue
165
+
166
+ # Get the current value
167
+ current_value = getattr(cli_args, attr_name)
168
+
169
+ # Check if this was explicitly set by the user
170
+ # For most types, we can infer this by checking against the defaults:
171
+ # - None values were not explicitly set
172
+ # - Empty lists were not explicitly set
173
+ # - False boolean flags were not explicitly set
174
+ # - Default numeric values (specific to each option) need special handling
175
+
176
+ should_apply = False
177
+
178
+ if isinstance(value, bool):
179
+ # For boolean flags, apply preset only if currently False (not set)
180
+ should_apply = not current_value
181
+ elif isinstance(current_value, list):
182
+ # For lists (like output), apply preset only if empty
183
+ should_apply = not current_value
184
+ elif current_value is None:
185
+ # For None values, always apply preset (not explicitly set)
186
+ should_apply = True
187
+ else:
188
+ # For other values (numbers, strings), check if they match known defaults
189
+ # This is conservative: only override if the value is a known default
190
+ if (
191
+ attr_name in FFmpegNormalize.DEFAULTS
192
+ and current_value == FFmpegNormalize.DEFAULTS[attr_name]
193
+ ):
194
+ should_apply = True
195
+
196
+ if should_apply:
197
+ setattr(cli_args, attr_name, value)
198
+ _logger.debug(f"Applied preset option '{key}' = {value}")
199
+
200
+ def validate_preset(self, preset_name: str) -> tuple[bool, str]:
201
+ """Validate that a preset file exists and is valid.
202
+
203
+ Args:
204
+ preset_name: Name of the preset to validate
205
+
206
+ Returns:
207
+ tuple[bool, str]: (is_valid, message)
208
+ """
209
+ try:
210
+ self.load_preset(preset_name)
211
+ return True, f"Preset '{preset_name}' is valid"
212
+ except FileNotFoundError as e:
213
+ return False, str(e)
214
+ except (json.JSONDecodeError, ValueError) as e:
215
+ return False, f"Error loading preset '{preset_name}': {e}"
216
+
217
+ def install_default_presets(self, force: bool = False) -> tuple[bool, str]:
218
+ """Install default presets to user config directory.
219
+
220
+ Args:
221
+ force: If True, overwrite existing presets
222
+
223
+ Returns:
224
+ tuple[bool, str]: (success, message)
225
+ """
226
+ try:
227
+ # Create presets directory if it doesn't exist
228
+ self.presets_dir.mkdir(parents=True, exist_ok=True)
229
+
230
+ if not self.default_presets_dir.exists():
231
+ return (
232
+ False,
233
+ f"Default presets directory not found at {self.default_presets_dir}",
234
+ )
235
+
236
+ # Copy all default presets
237
+ installed = []
238
+ skipped = []
239
+
240
+ for preset_file in self.default_presets_dir.glob("*.json"):
241
+ dest_file = self.presets_dir / preset_file.name
242
+
243
+ if dest_file.exists() and not force:
244
+ skipped.append(preset_file.name)
245
+ else:
246
+ shutil.copy2(preset_file, dest_file)
247
+ installed.append(preset_file.name)
248
+
249
+ message = f"Installed {len(installed)} preset(s) to {self.presets_dir}"
250
+ if skipped:
251
+ message += f" ({len(skipped)} skipped, use --force to overwrite)"
252
+
253
+ return True, message
254
+
255
+ except Exception as e:
256
+ return False, f"Error installing default presets: {e}"
@@ -0,0 +1,6 @@
1
+ {
2
+ "normalization-type": "rms",
3
+ "target-level": -20.0,
4
+ "batch": true,
5
+ "progress": true
6
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "normalization-type": "ebu",
3
+ "target-level": -16.0,
4
+ "loudness-range-target": 7.0,
5
+ "true-peak": -2.0,
6
+ "progress": true
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "normalization-type": "ebu",
3
+ "target-level": -14.0,
4
+ "loudness-range-target": 7.0,
5
+ "true-peak": -2.0,
6
+ "progress": true
7
+ }