ffmpeg-normalize 1.29.1__tar.gz → 1.30.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.
Files changed (28) hide show
  1. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/CHANGELOG.md +30 -0
  2. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/PKG-INFO +49 -6
  3. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/README.md +14 -2
  4. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/ffmpeg_normalize/__main__.py +15 -0
  5. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/ffmpeg_normalize/_ffmpeg_normalize.py +8 -2
  6. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/ffmpeg_normalize/_media_file.py +58 -21
  7. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/ffmpeg_normalize/_streams.py +63 -90
  8. ffmpeg_normalize-1.30.0/ffmpeg_normalize/_version.py +1 -0
  9. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/ffmpeg_normalize.egg-info/PKG-INFO +48 -5
  10. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/setup.py +4 -3
  11. ffmpeg-normalize-1.29.1/ffmpeg_normalize/_version.py +0 -1
  12. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/LICENSE +0 -0
  13. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/ffmpeg_normalize/__init__.py +0 -0
  14. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/ffmpeg_normalize/_cmd_utils.py +0 -0
  15. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/ffmpeg_normalize/_errors.py +0 -0
  16. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/ffmpeg_normalize/_logger.py +0 -0
  17. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/ffmpeg_normalize/py.typed +0 -0
  18. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/ffmpeg_normalize.egg-info/SOURCES.txt +0 -0
  19. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/ffmpeg_normalize.egg-info/dependency_links.txt +0 -0
  20. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/ffmpeg_normalize.egg-info/entry_points.txt +0 -0
  21. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/ffmpeg_normalize.egg-info/not-zip-safe +0 -0
  22. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/ffmpeg_normalize.egg-info/requires.txt +0 -0
  23. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/ffmpeg_normalize.egg-info/top_level.txt +0 -0
  24. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/setup.cfg +0 -0
  25. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/test/out.mp4 +0 -0
  26. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/test/test.mp4 +0 -0
  27. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/test/test.py +0 -0
  28. {ffmpeg-normalize-1.29.1 → ffmpeg_normalize-1.30.0}/test/test.wav +0 -0
@@ -1,6 +1,36 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## v1.30.0 (2024-11-22)
5
+
6
+ * Change lower-only message to warning.
7
+
8
+ * Make setup name PEP 625 compliant.
9
+
10
+ * Docs: add @ahmetsait as a contributor.
11
+
12
+ * Implement `--lower-only`
13
+
14
+ * Fix: `--print-stats` only outputs the last stream.
15
+
16
+ * More robust `loudnorm` output parsing.
17
+
18
+ * Remove unnecessary conversions.
19
+
20
+ * Update .editorconfig.
21
+
22
+ * Remove python 3.8, add python 3.12, 3.13.
23
+
24
+ * Add README on file size.
25
+
26
+
27
+ ## v1.29.2 (2024-11-18)
28
+
29
+ * Fix: show percentage with two decimal digits in progress.
30
+
31
+ * Chore: add python 12.
32
+
33
+
4
34
  ## v1.29.1 (2024-10-22)
5
35
 
6
36
  * Fix: override argparse usage.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
- Name: ffmpeg-normalize
3
- Version: 1.29.1
2
+ Name: ffmpeg_normalize
3
+ Version: 1.30.0
4
4
  Summary: Normalize audio via ffmpeg
5
5
  Home-page: https://github.com/slhck/ffmpeg-normalize
6
6
  Author: Werner Robitza
@@ -15,11 +15,12 @@ Classifier: Topic :: Multimedia :: Sound/Audio :: Conversion
15
15
  Classifier: License :: OSI Approved :: MIT License
16
16
  Classifier: Natural Language :: English
17
17
  Classifier: Programming Language :: Python :: 3
18
- Classifier: Programming Language :: Python :: 3.8
19
18
  Classifier: Programming Language :: Python :: 3.9
20
19
  Classifier: Programming Language :: Python :: 3.10
21
20
  Classifier: Programming Language :: Python :: 3.11
22
- Requires-Python: >=3.8
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Requires-Python: >=3.9
23
24
  Description-Content-Type: text/markdown
24
25
  License-File: LICENSE
25
26
 
@@ -30,7 +31,7 @@ License-File: LICENSE
30
31
  ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/slhck/ffmpeg-normalize/python-package.yml)
31
32
 
32
33
  <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
33
- [![All Contributors](https://img.shields.io/badge/all_contributors-19-orange.svg?style=flat-square)](#contributors-)
34
+ [![All Contributors](https://img.shields.io/badge/all_contributors-20-orange.svg?style=flat-square)](#contributors-)
34
35
  <!-- ALL-CONTRIBUTORS-BADGE:END -->
35
36
 
36
37
  A utility for batch-normalizing audio using ffmpeg.
@@ -68,6 +69,7 @@ Read on for more info.
68
69
  - [Environment Variables](#environment-variables)
69
70
  - [API](#api)
70
71
  - [FAQ](#faq)
72
+ - [My output file is too large?](#my-output-file-is-too-large)
71
73
  - [What options should I choose for the EBU R128 filter? What is linear and dynamic mode?](#what-options-should-i-choose-for-the-ebu-r128-filter-what-is-linear-and-dynamic-mode)
72
74
  - [The program doesn't work because the "loudnorm" filter can't be found](#the-program-doesnt-work-because-the-loudnorm-filter-cant-be-found)
73
75
  - [Should I use this to normalize my music collection?](#should-i-use-this-to-normalize-my-music-collection)
@@ -87,7 +89,7 @@ Read on for more info.
87
89
 
88
90
  ## Requirements
89
91
 
90
- You need Python 3.8 or higher, and ffmpeg.
92
+ You need Python 3.9 or higher, and ffmpeg.
91
93
 
92
94
  ### ffmpeg
93
95
 
@@ -404,6 +406,16 @@ For more information see the [API documentation](https://htmlpreview.github.io/?
404
406
 
405
407
  ## FAQ
406
408
 
409
+ ### My output file is too large?
410
+
411
+ This is because the default output codec is PCM, which is uncompressed. If you want to reduce the file size, you can specify an audio codec with `-c:a` (e.g., `-c:a aac` for ffmpeg's built-in AAC encoder), and optionally a bitrate with `-b:a`.
412
+
413
+ For example:
414
+
415
+ ```bash
416
+ ffmpeg-normalize input.wav -o output.m4a -c:a aac -b:a 192k
417
+ ```
418
+
407
419
  ### What options should I choose for the EBU R128 filter? What is linear and dynamic mode?
408
420
 
409
421
  EBU R128 is a method for normalizing audio loudness across different tracks or programs. It works by analyzing the audio content and adjusting it to meet specific loudness targets. The main components are:
@@ -547,6 +559,7 @@ If you found this program useful and feel like giving back, feel free to send a
547
559
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/psavva"><img src="https://avatars.githubusercontent.com/u/1454758?v=4?s=100" width="100px;" alt="Panayiotis Savva"/><br /><sub><b>Panayiotis Savva</b></sub></a><br /><a href="https://github.com/slhck/ffmpeg-normalize/commits?author=psavva" title="Code">💻</a></td>
548
560
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/HighMans"><img src="https://avatars.githubusercontent.com/u/42877729?v=4?s=100" width="100px;" alt="HighMans"/><br /><sub><b>HighMans</b></sub></a><br /><a href="https://github.com/slhck/ffmpeg-normalize/commits?author=HighMans" title="Code">💻</a></td>
549
561
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/kanjieater"><img src="https://avatars.githubusercontent.com/u/32607317?v=4?s=100" width="100px;" alt="kanjieater"/><br /><sub><b>kanjieater</b></sub></a><br /><a href="#ideas-kanjieater" title="Ideas, Planning, & Feedback">🤔</a></td>
562
+ <td align="center" valign="top" width="14.28%"><a href="https://ahmetsait.com/"><img src="https://avatars.githubusercontent.com/u/8372246?v=4?s=100" width="100px;" alt="Ahmet Sait"/><br /><sub><b>Ahmet Sait</b></sub></a><br /><a href="https://github.com/slhck/ffmpeg-normalize/commits?author=ahmetsait" title="Code">💻</a></td>
550
563
  </tr>
551
564
  </tbody>
552
565
  <tfoot>
@@ -594,6 +607,36 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
594
607
  # Changelog
595
608
 
596
609
 
610
+ ## v1.30.0 (2024-11-22)
611
+
612
+ * Change lower-only message to warning.
613
+
614
+ * Make setup name PEP 625 compliant.
615
+
616
+ * Docs: add @ahmetsait as a contributor.
617
+
618
+ * Implement `--lower-only`
619
+
620
+ * Fix: `--print-stats` only outputs the last stream.
621
+
622
+ * More robust `loudnorm` output parsing.
623
+
624
+ * Remove unnecessary conversions.
625
+
626
+ * Update .editorconfig.
627
+
628
+ * Remove python 3.8, add python 3.12, 3.13.
629
+
630
+ * Add README on file size.
631
+
632
+
633
+ ## v1.29.2 (2024-11-18)
634
+
635
+ * Fix: show percentage with two decimal digits in progress.
636
+
637
+ * Chore: add python 12.
638
+
639
+
597
640
  ## v1.29.1 (2024-10-22)
598
641
 
599
642
  * Fix: override argparse usage.
@@ -5,7 +5,7 @@
5
5
  ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/slhck/ffmpeg-normalize/python-package.yml)
6
6
 
7
7
  <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
8
- [![All Contributors](https://img.shields.io/badge/all_contributors-19-orange.svg?style=flat-square)](#contributors-)
8
+ [![All Contributors](https://img.shields.io/badge/all_contributors-20-orange.svg?style=flat-square)](#contributors-)
9
9
  <!-- ALL-CONTRIBUTORS-BADGE:END -->
10
10
 
11
11
  A utility for batch-normalizing audio using ffmpeg.
@@ -43,6 +43,7 @@ Read on for more info.
43
43
  - [Environment Variables](#environment-variables)
44
44
  - [API](#api)
45
45
  - [FAQ](#faq)
46
+ - [My output file is too large?](#my-output-file-is-too-large)
46
47
  - [What options should I choose for the EBU R128 filter? What is linear and dynamic mode?](#what-options-should-i-choose-for-the-ebu-r128-filter-what-is-linear-and-dynamic-mode)
47
48
  - [The program doesn't work because the "loudnorm" filter can't be found](#the-program-doesnt-work-because-the-loudnorm-filter-cant-be-found)
48
49
  - [Should I use this to normalize my music collection?](#should-i-use-this-to-normalize-my-music-collection)
@@ -62,7 +63,7 @@ Read on for more info.
62
63
 
63
64
  ## Requirements
64
65
 
65
- You need Python 3.8 or higher, and ffmpeg.
66
+ You need Python 3.9 or higher, and ffmpeg.
66
67
 
67
68
  ### ffmpeg
68
69
 
@@ -379,6 +380,16 @@ For more information see the [API documentation](https://htmlpreview.github.io/?
379
380
 
380
381
  ## FAQ
381
382
 
383
+ ### My output file is too large?
384
+
385
+ This is because the default output codec is PCM, which is uncompressed. If you want to reduce the file size, you can specify an audio codec with `-c:a` (e.g., `-c:a aac` for ffmpeg's built-in AAC encoder), and optionally a bitrate with `-b:a`.
386
+
387
+ For example:
388
+
389
+ ```bash
390
+ ffmpeg-normalize input.wav -o output.m4a -c:a aac -b:a 192k
391
+ ```
392
+
382
393
  ### What options should I choose for the EBU R128 filter? What is linear and dynamic mode?
383
394
 
384
395
  EBU R128 is a method for normalizing audio loudness across different tracks or programs. It works by analyzing the audio content and adjusting it to meet specific loudness targets. The main components are:
@@ -522,6 +533,7 @@ If you found this program useful and feel like giving back, feel free to send a
522
533
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/psavva"><img src="https://avatars.githubusercontent.com/u/1454758?v=4?s=100" width="100px;" alt="Panayiotis Savva"/><br /><sub><b>Panayiotis Savva</b></sub></a><br /><a href="https://github.com/slhck/ffmpeg-normalize/commits?author=psavva" title="Code">💻</a></td>
523
534
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/HighMans"><img src="https://avatars.githubusercontent.com/u/42877729?v=4?s=100" width="100px;" alt="HighMans"/><br /><sub><b>HighMans</b></sub></a><br /><a href="https://github.com/slhck/ffmpeg-normalize/commits?author=HighMans" title="Code">💻</a></td>
524
535
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/kanjieater"><img src="https://avatars.githubusercontent.com/u/32607317?v=4?s=100" width="100px;" alt="kanjieater"/><br /><sub><b>kanjieater</b></sub></a><br /><a href="#ideas-kanjieater" title="Ideas, Planning, & Feedback">🤔</a></td>
536
+ <td align="center" valign="top" width="14.28%"><a href="https://ahmetsait.com/"><img src="https://avatars.githubusercontent.com/u/8372246?v=4?s=100" width="100px;" alt="Ahmet Sait"/><br /><sub><b>Ahmet Sait</b></sub></a><br /><a href="https://github.com/slhck/ffmpeg-normalize/commits?author=ahmetsait" title="Code">💻</a></td>
525
537
  </tr>
526
538
  </tbody>
527
539
  <tfoot>
@@ -235,6 +235,20 @@ def create_parser() -> argparse.ArgumentParser:
235
235
  default=0.0,
236
236
  )
237
237
 
238
+ group_ebu.add_argument(
239
+ "--lower-only",
240
+ action="store_true",
241
+ help=textwrap.dedent(
242
+ """\
243
+ Whether the audio should not increase in loudness.
244
+
245
+ If the measured loudness from the first pass is lower than the target
246
+ loudness then normalization pass will be skipped for the measured audio
247
+ source.
248
+ """
249
+ ),
250
+ )
251
+
238
252
  group_ebu.add_argument(
239
253
  "--dual-mono",
240
254
  action="store_true",
@@ -514,6 +528,7 @@ def main() -> None:
514
528
  keep_lra_above_loudness_range_target=cli_args.keep_lra_above_loudness_range_target,
515
529
  true_peak=cli_args.true_peak,
516
530
  offset=cli_args.offset,
531
+ lower_only=cli_args.lower_only,
517
532
  dual_mono=cli_args.dual_mono,
518
533
  dynamic=cli_args.dynamic,
519
534
  audio_codec=cli_args.audio_codec,
@@ -3,6 +3,8 @@ from __future__ import annotations
3
3
  import json
4
4
  import logging
5
5
  import os
6
+ import sys
7
+ from itertools import chain
6
8
  from typing import TYPE_CHECKING, Literal
7
9
 
8
10
  from tqdm import tqdm
@@ -58,6 +60,7 @@ class FFmpegNormalize:
58
60
  keep_lra_above_loudness_range_target (bool, optional): Keep input loudness range above loudness range target. Defaults to False.
59
61
  true_peak (float, optional): True peak. Defaults to -2.0.
60
62
  offset (float, optional): Offset. Defaults to 0.0.
63
+ lower_only (bool, optional): Whether the audio should not increase in loudness. Defaults to False.
61
64
  dual_mono (bool, optional): Dual mono. Defaults to False.
62
65
  dynamic (bool, optional): Dynamic. Defaults to False.
63
66
  audio_codec (str, optional): Audio codec. Defaults to "pcm_s16le".
@@ -94,6 +97,7 @@ class FFmpegNormalize:
94
97
  keep_lra_above_loudness_range_target: bool = False,
95
98
  true_peak: float = -2.0,
96
99
  offset: float = 0.0,
100
+ lower_only: bool = False,
97
101
  dual_mono: bool = False,
98
102
  dynamic: bool = False,
99
103
  audio_codec: str = "pcm_s16le",
@@ -164,6 +168,7 @@ class FFmpegNormalize:
164
168
 
165
169
  self.true_peak = check_range(true_peak, -9, 0, name="true_peak")
166
170
  self.offset = check_range(offset, -99, 99, name="offset")
171
+ self.lower_only = lower_only
167
172
 
168
173
  # Ensure library user is passing correct types
169
174
  assert isinstance(dual_mono, bool), "dual_mono must be bool"
@@ -254,5 +259,6 @@ class FFmpegNormalize:
254
259
 
255
260
  _logger.info(f"Normalized file written to {media_file.output_file}")
256
261
 
257
- if self.print_stats and self.stats:
258
- print(json.dumps(self.stats, indent=4))
262
+ if self.print_stats:
263
+ json.dump(list(chain.from_iterable(media_file.get_stats() for media_file in self.media_files)), sys.stdout, indent=4)
264
+ print()
@@ -6,13 +6,18 @@ import re
6
6
  import shlex
7
7
  from shutil import move, rmtree
8
8
  from tempfile import mkdtemp
9
- from typing import TYPE_CHECKING, Iterator, Literal, TypedDict
9
+ from typing import TYPE_CHECKING, Iterable, Iterator, Literal, TypedDict
10
10
 
11
11
  from tqdm import tqdm
12
12
 
13
13
  from ._cmd_utils import DUR_REGEX, NUL, CommandRunner
14
14
  from ._errors import FFmpegNormalizeError
15
- from ._streams import AudioStream, SubtitleStream, VideoStream
15
+ from ._streams import (
16
+ AudioStream,
17
+ LoudnessStatisticsWithMetadata,
18
+ SubtitleStream,
19
+ VideoStream,
20
+ )
16
21
 
17
22
  if TYPE_CHECKING:
18
23
  from ffmpeg_normalize import FFmpegNormalize
@@ -24,6 +29,8 @@ _logger = logging.getLogger(__name__)
24
29
  AUDIO_ONLY_FORMATS = {"aac", "ast", "flac", "mka", "oga", "ogg", "opus", "wav"}
25
30
  ONE_STREAM = {"aac", "ast", "flac", "mp3", "wav"}
26
31
 
32
+ TQDM_BAR_FORMAT = "{desc}: {percentage:3.2f}% |{bar}{r_bar}"
33
+
27
34
 
28
35
  def _to_ms(**kwargs: str) -> int:
29
36
  hour = int(kwargs.get("hour", 0))
@@ -189,7 +196,12 @@ class MediaFile:
189
196
 
190
197
  # run the second pass as a whole
191
198
  if self.ffmpeg_normalize.progress:
192
- with tqdm(total=100, position=1, desc="Second Pass") as pbar:
199
+ with tqdm(
200
+ total=100,
201
+ position=1,
202
+ desc="Second Pass",
203
+ bar_format=TQDM_BAR_FORMAT,
204
+ ) as pbar:
193
205
  for progress in self._second_pass():
194
206
  pbar.update(progress - pbar.n)
195
207
  else:
@@ -225,6 +237,7 @@ class MediaFile:
225
237
  total=100,
226
238
  position=1,
227
239
  desc=f"Stream {index + 1}/{len(self.streams['audio'].values())}",
240
+ bar_format=TQDM_BAR_FORMAT,
228
241
  ) as pbar:
229
242
  for progress in fun():
230
243
  pbar.update(progress - pbar.n)
@@ -232,11 +245,6 @@ class MediaFile:
232
245
  for _ in fun():
233
246
  pass
234
247
 
235
- # set initial stats (for dry-runs, this is the only thing we need to do)
236
- self.ffmpeg_normalize.stats = [
237
- audio_stream.get_stats() for audio_stream in self.streams["audio"].values()
238
- ]
239
-
240
248
  def _get_audio_filter_cmd(self) -> tuple[str, list[str]]:
241
249
  """
242
250
  Return the audio filter command and output labels needed.
@@ -248,10 +256,40 @@ class MediaFile:
248
256
  output_labels = []
249
257
 
250
258
  for audio_stream in self.streams["audio"].values():
251
- if self.ffmpeg_normalize.normalization_type == "ebu":
252
- normalization_filter = audio_stream.get_second_pass_opts_ebu()
259
+ skip_normalization = False
260
+ if self.ffmpeg_normalize.lower_only:
261
+ if self.ffmpeg_normalize.normalization_type == "ebu":
262
+ if (
263
+ audio_stream.loudness_statistics["ebu_pass1"] is not None
264
+ and audio_stream.loudness_statistics["ebu_pass1"]["input_i"]
265
+ < self.ffmpeg_normalize.target_level
266
+ ):
267
+ skip_normalization = True
268
+ elif self.ffmpeg_normalize.normalization_type == "peak":
269
+ if (
270
+ audio_stream.loudness_statistics["max"] is not None
271
+ and audio_stream.loudness_statistics["max"]
272
+ < self.ffmpeg_normalize.target_level
273
+ ):
274
+ skip_normalization = True
275
+ elif self.ffmpeg_normalize.normalization_type == "rms":
276
+ if (
277
+ audio_stream.loudness_statistics["mean"] is not None
278
+ and audio_stream.loudness_statistics["mean"]
279
+ < self.ffmpeg_normalize.target_level
280
+ ):
281
+ skip_normalization = True
282
+
283
+ if skip_normalization:
284
+ _logger.warn(
285
+ f"Stream {audio_stream.stream_id} had measured input loudness lower than target, skipping normalization."
286
+ )
287
+ normalization_filter = "acopy"
253
288
  else:
254
- normalization_filter = audio_stream.get_second_pass_opts_peakrms()
289
+ if self.ffmpeg_normalize.normalization_type == "ebu":
290
+ normalization_filter = audio_stream.get_second_pass_opts_ebu()
291
+ else:
292
+ normalization_filter = audio_stream.get_second_pass_opts_peakrms()
255
293
 
256
294
  input_label = f"[0:{audio_stream.stream_id}]"
257
295
  output_label = f"[norm{audio_stream.stream_id}]"
@@ -413,16 +451,10 @@ class MediaFile:
413
451
  # in the second pass, we do not normalize stream-by-stream, so we set the stats based on the
414
452
  # overall output (which includes multiple loudnorm stats)
415
453
  if self.ffmpeg_normalize.normalization_type == "ebu":
416
- all_stats = AudioStream.prune_and_parse_loudnorm_output(
417
- output, num_stats=len(self.streams["audio"])
418
- )
419
- for idx, audio_stream in enumerate(self.streams["audio"].values()):
420
- audio_stream.set_second_pass_stats(all_stats[idx])
421
-
422
- # collect all stats for the final report, again (overwrite the input)
423
- self.ffmpeg_normalize.stats = [
424
- audio_stream.get_stats() for audio_stream in self.streams["audio"].values()
425
- ]
454
+ all_stats = AudioStream.prune_and_parse_loudnorm_output(output)
455
+ for stream_id, audio_stream in self.streams["audio"].items():
456
+ if stream_id in all_stats:
457
+ audio_stream.set_second_pass_stats(all_stats[stream_id])
426
458
 
427
459
  # warn if self.media_file.ffmpeg_normalize.dynamic == False and any of the second pass stats contain "normalization_type" == "dynamic"
428
460
  if self.ffmpeg_normalize.dynamic is False:
@@ -438,3 +470,8 @@ class MediaFile:
438
470
  )
439
471
 
440
472
  _logger.debug("Normalization finished")
473
+
474
+ def get_stats(self) -> Iterable[LoudnessStatisticsWithMetadata]:
475
+ return (
476
+ audio_stream.get_stats() for audio_stream in self.streams["audio"].values()
477
+ )
@@ -15,6 +15,7 @@ if TYPE_CHECKING:
15
15
 
16
16
  _logger = logging.getLogger(__name__)
17
17
 
18
+ _loudnorm_pattern = re.compile(r"\[Parsed_loudnorm_(\d+)")
18
19
 
19
20
  class EbuLoudnessStatistics(TypedDict):
20
21
  input_i: float
@@ -320,58 +321,36 @@ class AudioStream(MediaStream):
320
321
  f"Loudnorm first pass command output: {CommandRunner.prune_ffmpeg_progress_from_output(output)}"
321
322
  )
322
323
 
323
- self.loudness_statistics["ebu_pass1"] = (
324
- AudioStream.prune_and_parse_loudnorm_output(
325
- output, num_stats=1
326
- )[0] # only one stream
327
- )
324
+ # only one stream
325
+ self.loudness_statistics["ebu_pass1"] = next(iter(AudioStream.prune_and_parse_loudnorm_output(output).values()))
328
326
 
329
327
  @staticmethod
330
328
  def prune_and_parse_loudnorm_output(
331
- output: str, num_stats: int = 1
332
- ) -> List[EbuLoudnessStatistics]:
329
+ output: str
330
+ ) -> dict[int, EbuLoudnessStatistics]:
333
331
  """
334
332
  Prune ffmpeg progress lines from output and parse the loudnorm filter output.
335
333
  There may be multiple outputs if multiple streams were processed.
336
334
 
337
335
  Args:
338
336
  output (str): The output from ffmpeg.
339
- num_stats (int): The number of loudnorm statistics to parse.
340
337
 
341
338
  Returns:
342
339
  list: The EBU loudness statistics.
343
340
  """
344
341
  pruned_output = CommandRunner.prune_ffmpeg_progress_from_output(output)
345
342
  output_lines = [line.strip() for line in pruned_output.split("\n")]
346
-
347
- ret = []
348
- idx = 0
349
- while True:
350
- _logger.debug(f"Parsing loudnorm stats for stream {idx}")
351
- loudnorm_stats = AudioStream._parse_loudnorm_output(
352
- output_lines, stream_index=idx
353
- )
354
- idx += 1
355
-
356
- if loudnorm_stats is None:
357
- continue
358
- ret.append(loudnorm_stats)
359
-
360
- if len(ret) >= num_stats:
361
- break
362
-
363
- return ret
343
+ return AudioStream._parse_loudnorm_output(output_lines)
364
344
 
365
345
  @staticmethod
366
346
  def _parse_loudnorm_output(
367
- output_lines: list[str], stream_index: Optional[int] = None
368
- ) -> Optional[EbuLoudnessStatistics]:
347
+ output_lines: list[str]
348
+ ) -> dict[int, EbuLoudnessStatistics]:
369
349
  """
370
350
  Parse the output of a loudnorm filter to get the EBU loudness statistics.
371
351
 
372
352
  Args:
373
353
  output_lines (list[str]): The output lines of the loudnorm filter.
374
- stream_index (int): The stream index, optional to filter out the correct stream. If unset, the first stream is used.
375
354
 
376
355
  Raises:
377
356
  FFmpegNormalizeError: When the output could not be parsed.
@@ -379,64 +358,58 @@ class AudioStream(MediaStream):
379
358
  Returns:
380
359
  EbuLoudnessStatistics: The EBU loudness statistics, if found.
381
360
  """
361
+ result = dict[int, EbuLoudnessStatistics]()
362
+ stream_index = -1
382
363
  loudnorm_start = 0
383
- loudnorm_end = 0
384
364
  for index, line in enumerate(output_lines):
385
- if line.startswith(f"[Parsed_loudnorm_{stream_index}"):
386
- loudnorm_start = index + 1
387
- continue
388
- if loudnorm_start and line.startswith("}"):
389
- loudnorm_end = index + 1
390
- break
391
-
392
- if not (loudnorm_start and loudnorm_end):
393
- if stream_index is not None:
394
- # not an error
395
- return None
396
-
397
- raise FFmpegNormalizeError(
398
- "Could not parse loudnorm stats; no loudnorm-related output found"
399
- )
400
-
401
- try:
402
- loudnorm_stats = json.loads(
403
- "\n".join(output_lines[loudnorm_start:loudnorm_end])
404
- )
405
-
406
- _logger.debug(
407
- f"Loudnorm stats for stream {stream_index} parsed: {json.dumps(loudnorm_stats)}"
408
- )
409
-
410
- for key in [
411
- "input_i",
412
- "input_tp",
413
- "input_lra",
414
- "input_thresh",
415
- "output_i",
416
- "output_tp",
417
- "output_lra",
418
- "output_thresh",
419
- "target_offset",
420
- "normalization_type",
421
- ]:
422
- if key not in loudnorm_stats:
423
- continue
424
- if key == "normalization_type":
425
- loudnorm_stats[key] = loudnorm_stats[key].lower()
426
- # handle infinite values
427
- elif float(loudnorm_stats[key]) == -float("inf"):
428
- loudnorm_stats[key] = -99
429
- elif float(loudnorm_stats[key]) == float("inf"):
430
- loudnorm_stats[key] = 0
431
- else:
432
- # convert to floats
433
- loudnorm_stats[key] = float(loudnorm_stats[key])
434
-
435
- return cast(EbuLoudnessStatistics, loudnorm_stats)
436
- except Exception as e:
437
- raise FFmpegNormalizeError(
438
- f"Could not parse loudnorm stats; wrong JSON format in string: {e}"
439
- )
365
+ if stream_index < 0:
366
+ if m := _loudnorm_pattern.match(line):
367
+ loudnorm_start = index + 1
368
+ stream_index = int(m.group(1))
369
+ else:
370
+ if line.startswith("}"):
371
+ loudnorm_end = index + 1
372
+ loudnorm_data = "\n".join(output_lines[loudnorm_start:loudnorm_end])
373
+
374
+ try:
375
+ loudnorm_stats = json.loads(loudnorm_data)
376
+
377
+ _logger.debug(
378
+ f"Loudnorm stats for stream {stream_index} parsed: {loudnorm_data}"
379
+ )
380
+
381
+ for key in [
382
+ "input_i",
383
+ "input_tp",
384
+ "input_lra",
385
+ "input_thresh",
386
+ "output_i",
387
+ "output_tp",
388
+ "output_lra",
389
+ "output_thresh",
390
+ "target_offset",
391
+ "normalization_type",
392
+ ]:
393
+ if key not in loudnorm_stats:
394
+ continue
395
+ if key == "normalization_type":
396
+ loudnorm_stats[key] = loudnorm_stats[key].lower()
397
+ # handle infinite values
398
+ elif float(loudnorm_stats[key]) == -float("inf"):
399
+ loudnorm_stats[key] = -99
400
+ elif float(loudnorm_stats[key]) == float("inf"):
401
+ loudnorm_stats[key] = 0
402
+ else:
403
+ # convert to floats
404
+ loudnorm_stats[key] = float(loudnorm_stats[key])
405
+
406
+ result[stream_index] = cast(EbuLoudnessStatistics, loudnorm_stats)
407
+ stream_index = -1
408
+ except Exception as e:
409
+ raise FFmpegNormalizeError(
410
+ f"Could not parse loudnorm stats; wrong JSON format in string: {e}"
411
+ )
412
+ return result
440
413
 
441
414
  def get_second_pass_opts_ebu(self) -> str:
442
415
  """
@@ -515,19 +488,19 @@ class AudioStream(MediaStream):
515
488
  "lra": self.media_file.ffmpeg_normalize.loudness_range_target,
516
489
  "tp": self.media_file.ffmpeg_normalize.true_peak,
517
490
  "offset": self._constrain(
518
- float(stats["target_offset"]), -99, 99, name="target_offset"
491
+ stats["target_offset"], -99, 99, name="target_offset"
519
492
  ),
520
493
  "measured_i": self._constrain(
521
- float(stats["input_i"]), -99, 0, name="input_i"
494
+ stats["input_i"], -99, 0, name="input_i"
522
495
  ),
523
496
  "measured_lra": self._constrain(
524
- float(stats["input_lra"]), 0, 99, name="input_lra"
497
+ stats["input_lra"], 0, 99, name="input_lra"
525
498
  ),
526
499
  "measured_tp": self._constrain(
527
- float(stats["input_tp"]), -99, 99, name="input_tp"
500
+ stats["input_tp"], -99, 99, name="input_tp"
528
501
  ),
529
502
  "measured_thresh": self._constrain(
530
- float(stats["input_thresh"]), -99, 0, name="input_thresh"
503
+ stats["input_thresh"], -99, 0, name="input_thresh"
531
504
  ),
532
505
  "linear": "false" if self.media_file.ffmpeg_normalize.dynamic else "true",
533
506
  "print_format": "json",
@@ -0,0 +1 @@
1
+ __version__ = "1.30.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ffmpeg-normalize
3
- Version: 1.29.1
3
+ Version: 1.30.0
4
4
  Summary: Normalize audio via ffmpeg
5
5
  Home-page: https://github.com/slhck/ffmpeg-normalize
6
6
  Author: Werner Robitza
@@ -15,11 +15,12 @@ Classifier: Topic :: Multimedia :: Sound/Audio :: Conversion
15
15
  Classifier: License :: OSI Approved :: MIT License
16
16
  Classifier: Natural Language :: English
17
17
  Classifier: Programming Language :: Python :: 3
18
- Classifier: Programming Language :: Python :: 3.8
19
18
  Classifier: Programming Language :: Python :: 3.9
20
19
  Classifier: Programming Language :: Python :: 3.10
21
20
  Classifier: Programming Language :: Python :: 3.11
22
- Requires-Python: >=3.8
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Requires-Python: >=3.9
23
24
  Description-Content-Type: text/markdown
24
25
  License-File: LICENSE
25
26
 
@@ -30,7 +31,7 @@ License-File: LICENSE
30
31
  ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/slhck/ffmpeg-normalize/python-package.yml)
31
32
 
32
33
  <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
33
- [![All Contributors](https://img.shields.io/badge/all_contributors-19-orange.svg?style=flat-square)](#contributors-)
34
+ [![All Contributors](https://img.shields.io/badge/all_contributors-20-orange.svg?style=flat-square)](#contributors-)
34
35
  <!-- ALL-CONTRIBUTORS-BADGE:END -->
35
36
 
36
37
  A utility for batch-normalizing audio using ffmpeg.
@@ -68,6 +69,7 @@ Read on for more info.
68
69
  - [Environment Variables](#environment-variables)
69
70
  - [API](#api)
70
71
  - [FAQ](#faq)
72
+ - [My output file is too large?](#my-output-file-is-too-large)
71
73
  - [What options should I choose for the EBU R128 filter? What is linear and dynamic mode?](#what-options-should-i-choose-for-the-ebu-r128-filter-what-is-linear-and-dynamic-mode)
72
74
  - [The program doesn't work because the "loudnorm" filter can't be found](#the-program-doesnt-work-because-the-loudnorm-filter-cant-be-found)
73
75
  - [Should I use this to normalize my music collection?](#should-i-use-this-to-normalize-my-music-collection)
@@ -87,7 +89,7 @@ Read on for more info.
87
89
 
88
90
  ## Requirements
89
91
 
90
- You need Python 3.8 or higher, and ffmpeg.
92
+ You need Python 3.9 or higher, and ffmpeg.
91
93
 
92
94
  ### ffmpeg
93
95
 
@@ -404,6 +406,16 @@ For more information see the [API documentation](https://htmlpreview.github.io/?
404
406
 
405
407
  ## FAQ
406
408
 
409
+ ### My output file is too large?
410
+
411
+ This is because the default output codec is PCM, which is uncompressed. If you want to reduce the file size, you can specify an audio codec with `-c:a` (e.g., `-c:a aac` for ffmpeg's built-in AAC encoder), and optionally a bitrate with `-b:a`.
412
+
413
+ For example:
414
+
415
+ ```bash
416
+ ffmpeg-normalize input.wav -o output.m4a -c:a aac -b:a 192k
417
+ ```
418
+
407
419
  ### What options should I choose for the EBU R128 filter? What is linear and dynamic mode?
408
420
 
409
421
  EBU R128 is a method for normalizing audio loudness across different tracks or programs. It works by analyzing the audio content and adjusting it to meet specific loudness targets. The main components are:
@@ -547,6 +559,7 @@ If you found this program useful and feel like giving back, feel free to send a
547
559
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/psavva"><img src="https://avatars.githubusercontent.com/u/1454758?v=4?s=100" width="100px;" alt="Panayiotis Savva"/><br /><sub><b>Panayiotis Savva</b></sub></a><br /><a href="https://github.com/slhck/ffmpeg-normalize/commits?author=psavva" title="Code">💻</a></td>
548
560
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/HighMans"><img src="https://avatars.githubusercontent.com/u/42877729?v=4?s=100" width="100px;" alt="HighMans"/><br /><sub><b>HighMans</b></sub></a><br /><a href="https://github.com/slhck/ffmpeg-normalize/commits?author=HighMans" title="Code">💻</a></td>
549
561
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/kanjieater"><img src="https://avatars.githubusercontent.com/u/32607317?v=4?s=100" width="100px;" alt="kanjieater"/><br /><sub><b>kanjieater</b></sub></a><br /><a href="#ideas-kanjieater" title="Ideas, Planning, & Feedback">🤔</a></td>
562
+ <td align="center" valign="top" width="14.28%"><a href="https://ahmetsait.com/"><img src="https://avatars.githubusercontent.com/u/8372246?v=4?s=100" width="100px;" alt="Ahmet Sait"/><br /><sub><b>Ahmet Sait</b></sub></a><br /><a href="https://github.com/slhck/ffmpeg-normalize/commits?author=ahmetsait" title="Code">💻</a></td>
550
563
  </tr>
551
564
  </tbody>
552
565
  <tfoot>
@@ -594,6 +607,36 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
594
607
  # Changelog
595
608
 
596
609
 
610
+ ## v1.30.0 (2024-11-22)
611
+
612
+ * Change lower-only message to warning.
613
+
614
+ * Make setup name PEP 625 compliant.
615
+
616
+ * Docs: add @ahmetsait as a contributor.
617
+
618
+ * Implement `--lower-only`
619
+
620
+ * Fix: `--print-stats` only outputs the last stream.
621
+
622
+ * More robust `loudnorm` output parsing.
623
+
624
+ * Remove unnecessary conversions.
625
+
626
+ * Update .editorconfig.
627
+
628
+ * Remove python 3.8, add python 3.12, 3.13.
629
+
630
+ * Add README on file size.
631
+
632
+
633
+ ## v1.29.2 (2024-11-18)
634
+
635
+ * Fix: show percentage with two decimal digits in progress.
636
+
637
+ * Chore: add python 12.
638
+
639
+
597
640
  ## v1.29.1 (2024-10-22)
598
641
 
599
642
  * Fix: override argparse usage.
@@ -19,7 +19,7 @@ with open(path.join(here, "CHANGELOG.md"), encoding="utf8") as f:
19
19
  history = f.read()
20
20
 
21
21
  setup(
22
- name="ffmpeg-normalize",
22
+ name="ffmpeg_normalize",
23
23
  version=version,
24
24
  description="Normalize audio via ffmpeg",
25
25
  long_description=long_description + "\n\n" + history,
@@ -50,12 +50,13 @@ setup(
50
50
  "License :: OSI Approved :: MIT License",
51
51
  "Natural Language :: English",
52
52
  "Programming Language :: Python :: 3",
53
- "Programming Language :: Python :: 3.8",
54
53
  "Programming Language :: Python :: 3.9",
55
54
  "Programming Language :: Python :: 3.10",
56
55
  "Programming Language :: Python :: 3.11",
56
+ "Programming Language :: Python :: 3.12",
57
+ "Programming Language :: Python :: 3.13",
57
58
  ],
58
- python_requires=">=3.8",
59
+ python_requires=">=3.9",
59
60
  entry_points={
60
61
  "console_scripts": ["ffmpeg-normalize = ffmpeg_normalize.__main__:main"]
61
62
  },
@@ -1 +0,0 @@
1
- __version__ = "1.29.1"