Skip to content

Instantly share code, notes, and snippets.

@YahyaHammad
Last active August 23, 2026 15:34
Show Gist options
  • Select an option

  • Save YahyaHammad/73addbeb34201693856fcebc5babafcd to your computer and use it in GitHub Desktop.

Select an option

Save YahyaHammad/73addbeb34201693856fcebc5babafcd to your computer and use it in GitHub Desktop.
MP4 video to BND Flipper Zero-compatible video format conversion script

Flipper Zero Video Converter (with audio normalization)

Converts ordinary video files into .bnd bundles that the Video Player app by LTVA can play on a Flipper Zero, using FFmpeg for all the heavy lifting.

This is a modified version of JacobTDC's converter.py. All of the original conversion logic — dithering, scaling, frame-rate conversion, the bundle format, the sample-drop desync fix — is JacobTDC's work. The additions here are about audio volume: the player app has no volume control, so a video whose audio was mastered quietly ends up nearly inaudible on the Flipper's speaker. This version measures and normalizes the audio so it comes out loud and clear.


What's different from the original

Change Detail
Audio normalization New -n/--normalize option with four modes, defaulting to dynamic. See Audio normalization.
Audio tuning knobs -l/--level, -c/--compress, --highpass and -g/--gain to adjust the normalization.
Custom FFmpeg paths --ffmpeg / --ffprobe (or the FFMPEG_BINARY / FFPROBE_BINARY environment variables) to use a specific FFmpeg build instead of whatever is on PATH.
Readable errors If FFmpeg can't be found or Windows refuses to start it, you get an explanation instead of a Python traceback.
Extra info line The summary printed before conversion now includes what normalization will be applied, plus the measured levels.

Nothing else about the output changed: running with -n none produces a .bnd file that is byte-for-byte identical to the original script's output.


Requirements

  • Python 3.8 or newer
  • FFmpeg — both the ffmpeg and ffprobe executables
  • ffmpeg-python — the Python wrapper module

Installation

1. Python

Windows: install from python.org (tick Add python.exe to PATH during setup) or run winget install Python.Python.3.13.

macOS: brew install python

Linux: it's almost certainly already installed; otherwise sudo apt install python3 python3-pip.

2. FFmpeg

FFmpeg is a separate program, not a Python package. You need it on your PATH (or you can point the script at it later with --ffmpeg / --ffprobe).

Windows — winget (easiest):

winget install Gyan.FFmpeg

Then close and reopen your terminal so the new PATH takes effect.

Windows — manual: download a build from gyan.dev or BtbN, extract it somewhere permanent (e.g. C:\ffmpeg), then add the bin folder to your PATH:

Settings → System → About → Advanced system settings → Environment Variables → Path → Edit → New → C:\ffmpeg\bin

macOS: brew install ffmpeg

Linux: sudo apt install ffmpeg (Debian/Ubuntu), sudo dnf install ffmpeg (Fedora), sudo pacman -S ffmpeg (Arch)

3. ffmpeg-python

pip install ffmpeg-python

⚠️ Watch the name — it is ffmpeg-python, not ffmpeg or python-ffmpeg. Those are different, incompatible packages and the script will not work with them.

4. Check that everything is there

ffmpeg -version
ffprobe -version
python -c "import ffmpeg; print('ffmpeg-python ok')"

All three should print something rather than an error. If they do, you're ready.


Usage

python converter.py source.mp4 output.bnd

That's it — the defaults pick the best fit for the Flipper's 128×64 screen, keep the source frame rate and sample rate, apply sierra3 dithering, and normalize the audio.

A more explicit example matching what the player app documents (mono audio at 44.1 kHz, 15 or 30 fps):

python converter.py source.mp4 output.bnd -f 30 -r 44100

Then copy the .bnd file to your Flipper's SD card under:

apps_data/video_player/

…and open it from the Video Player app. If playback runs at the wrong speed or drifts out of sync, try the explicit -f 30 -r 44100 above — the player app's own documentation describes its format as mono 8-bit PCM at 44100 Hz with a 15 or 30 fps video track.

Heads up on file size. These bundles are raw, uncompressed video and audio. A minute of 128×64 @ 30 fps with 44.1 kHz audio is about 4.3 MB. The script prints an estimate before it starts.


Options

Video

Option Description
-d, --dither ALGORITHM Dithering algorithm: bayer, heckbert, floyd_steinberg, sierra2, sierra2_4a, sierra3, burkes, atkinson, none. Default sierra3.
--bayer-scale N Crosshatch scale (0–5) for -d bayer.
-t, --threshold N Plain black/white threshold (0–256) instead of dithering. Mutually exclusive with -d.
-f, --frame-rate N Output frame rate; may be a fraction like 30000/1001. Defaults to the source.
-s, --scale WxH Output size, up to 128×64. Default is the largest fit that keeps the aspect ratio. Width is padded up to a multiple of 8 automatically.

Audio

Option Description
-r, --sample-rate N Output sample rate in Hz. Defaults to the source.
-n, --normalize MODE dynamic (default), loudness, peak, or none. See below.
-l, --level N Target level for loudness (LUFS, default −9) or peak (dBFS, default −1).
-c, --compress N Dynamic-range compression before normalizing, 1–30 where lower means more compression, or 0 to disable. Default 5. dynamic mode only.
--highpass N Roll off audio below N Hz before normalizing. Default 200. 0 disables.
-g, --gain N Extra gain in dB applied after normalization. Default 0.

General

Option Description
--ffmpeg PATH Which ffmpeg executable to use. Also settable via FFMPEG_BINARY.
--ffprobe PATH Which ffprobe executable to use. Also settable via FFPROBE_BINARY.
-q, --quiet Suppress info output. Use twice (-qq) to also suppress warnings.

Audio normalization

The Flipper's speaker is perfectly capable of being heard across a room — the problem is that most videos are mastered with their peaks well below full scale, and the player app has no volume control to make up the difference. So the script does it at conversion time.

Every mode except none works on exactly the signal the Flipper will play (mono, at the final sample rate), rolls off everything below 200 Hz first (the speaker can't reproduce it, and it only eats headroom), and finishes with a limiter so nothing clips into the 8-bit output.

dynamic (default) — loudest, best for speech and most videos

Measures the file, lifts the whole thing up to just under full scale, then runs a dynamic normalizer that keeps every passage near that ceiling. A whispered line and an explosion come out at similar volume. This is what you want if the goal is "I can hear it without holding the Flipper to my ear."

python converter.py source.mp4 output.bnd

loudness — broadcast-style, more natural

Two-pass EBU R128 normalization to −9 LUFS (adjustable with -l). Applies one constant gain to the whole file, so the original dynamics are preserved. Quiet scenes stay quiet.

python converter.py source.mp4 output.bnd -n loudness
python converter.py source.mp4 output.bnd -n loudness -l -6   # louder

peak — most conservative

Two-pass peak normalization: finds the loudest sample and scales the file so it lands at −1 dBFS (adjustable with -l). Nothing else is touched. If a video is already well mastered and just needs a lift, this is the transparent option.

none — original behavior

No audio processing at all. Produces output identical to the original script.

Tuning

  • Still not loud enough? Add -g 3 (or more). The limiter keeps it from clipping, but pushing hard will start to sound crunchy.
  • Sounds over-compressed or "pumpy"? Raise the compress factor (-c 15), or switch to -n loudness.
  • Hearing hiss or background noise in quiet moments? That's normalization doing its job on a noisy source — try -n loudness or -n peak instead, which don't lift quiet passages.
  • Want the bass back? --highpass 0. It will sound fuller on headphones and no different on the Flipper, but you lose some loudness.
  • Nearly silent audio track? The script detects it (peak below −60 dBFS), skips normalization and warns instead of amplifying pure noise.

Troubleshooting

OSError: [WinError 4551] An Application Control policy has blocked this file

What it means: Windows blocked the FFmpeg executable from starting. This is Smart App Control (on by default on clean installs of Windows 11 22H2 and newer), and it is judging ffmpeg.exe / ffprobe.exe, not this script. The script's only involvement is asking Windows to run FFmpeg; Python reports whatever Windows says back.

Confirm it for yourself — in PowerShell, run:

ffmpeg -version
ffprobe -version

If either one fails with the same message, the block has nothing to do with the script.

Why it can appear out of nowhere: Smart App Control checks each binary against Microsoft's cloud reputation service. Most FFmpeg Windows builds are not code-signed, so the verdict depends on that build's reputation — which means a winget upgrade that swaps in a new FFmpeg build, or a change on Microsoft's side, can flip a binary from allowed to blocked without you changing anything.

Options, roughly in order of least disruption:

  1. Try a different FFmpeg build. The policy decides per binary, so another build may run fine. For example:

    winget install BtbN.FFmpeg.GPL

    or Gyan.FFmpeg.Shared, or a manual download from gyan.dev / BtbN. Then point the script at it:

    python converter.py source.mp4 output.bnd --ffmpeg "C:\ffmpeg\bin\ffmpeg.exe" --ffprobe "C:\ffmpeg\bin\ffprobe.exe"

    or set it once per session:

    $env:FFMPEG_BINARY = "C:\ffmpeg\bin\ffmpeg.exe"
    $env:FFPROBE_BINARY = "C:\ffmpeg\bin\ffprobe.exe"
  2. Run the conversion in WSL, where Windows app-control policies don't apply:

    wsl --install

    then inside WSL:

    sudo apt update && sudo apt install ffmpeg python3-pip
    pip install ffmpeg-python
    python3 converter.py /mnt/c/Users/you/Videos/source.mp4 output.bnd
  3. Turn Smart App Control offWindows Security → App & browser control → Smart App Control → Off. ⚠️ This is one-way: Microsoft does not support turning it back on without reinstalling Windows, so treat it as a last resort. Microsoft does not provide per-app exclusions for Smart App Control.

A note on trust: no script can (or should) bypass a Windows Application Control policy — that's the whole point of the feature. Anything claiming to disable or work around Smart App Control from inside a script deserves your suspicion. All this script does is let you choose which FFmpeg binary it runs.

error: could not find 'ffmpeg'

FFmpeg isn't on your PATH. Either add it (see Installation) or pass --ffmpeg / --ffprobe with full paths. On Windows, remember to reopen your terminal after changing PATH.

ModuleNotFoundError: No module named 'ffmpeg'

Install the wrapper: pip install ffmpeg-python. If you have several Pythons installed, use python -m pip install ffmpeg-python so it lands in the interpreter you're actually running.

source file does not contain a default audio stream

The bundle format requires both video and audio. If your source has no audio, add a silent track first:

ffmpeg -i source.mp4 -f lavfi -i anullsrc=r=44100:cl=mono -shortest -c:v copy -c:a aac with_audio.mp4

warning: number of bytes written does not match estimated file size

The audio and video streams in the source don't have exactly the same duration, so one ran out before the other. Usually harmless, but if playback cuts short, re-encode the source so both streams are the same length (-shortest).

Audio and video drift apart

Try an exact -f 15 or -f 30 with -r 44100. Fractional source frame rates (29.97) mean the converter has to drop or duplicate frames, and the player app's timing assumes the rates it documents.


Credits

  • Original converter: JacobTDC — all of the bundle format handling, video processing and A/V sync logic.
  • Player app: Video Player by LTVA (Flipper Lab).
  • Audio normalization additions: this fork.

License

The original gist does not state a license, so it remains under whatever terms JacobTDC chooses; credits to them for the original work.

The modifications in this version (audio normalization, FFmpeg path options, error handling and this README) are released under the MIT License:

Copyright (c) 2026 Yahya Hammad

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
#!/usr/bin/env python3
import argparse
import json
import locale
import math
import os
import re
import struct
import subprocess
import sys
import textwrap
from fractions import Fraction
from pathlib import Path
import ffmpeg
# set locale to user default
locale.setlocale(locale.LC_ALL, '')
# just some "constants"
BUNDLE_SIGNATURE = "BND!VID"
BUNDLE_VERSION = 1
SCREEN_WIDTH = 128
SCREEN_HEIGHT = 64
# audio normalization defaults
# the flipper's speaker is small, has no volume control in the player app and
# reproduces almost nothing below a few hundred Hz, so the audio is rolled off
# at the low end, compressed and pushed close to full scale
DEFAULT_HIGHPASS = 200 # Hz, 0 disables
DEFAULT_COMPRESS = 5.0 # dynaudnorm compress factor, lower = stronger
DEFAULT_MAX_GAIN = 20.0 # dynaudnorm maximum local gain factor
DEFAULT_PEAK_LEVEL = -1.0 # dBFS, target for '--normalize peak'
DEFAULT_LOUDNESS_LEVEL = -9.0 # LUFS, target for '--normalize loudness'
LOUDNESS_RANGE = 7.0 # LUFS, target loudness range for loudnorm
LIMITER_CEILING = 0.95 # safety limiter ceiling, linear
MAX_STATIC_GAIN = 60.0 # dB, backstop so noise doesn't get blown up
SILENCE_LEVEL = -60.0 # dBFS, quieter than this counts as silence
# a class to make sure a valid scale is given in args
class VideoScale:
def __init__(self, scale):
[self.width, self.height] = list(map(int, scale.split('x')))
if not (1 <= self.width <= SCREEN_WIDTH and 1 <= self.height <= SCREEN_HEIGHT):
raise argparse.ArgumentTypeError(f"{scale} is not in range 1x1 to {SCREEN_WIDTH:d}x{SCREEN_HEIGHT:d}")
def __str__(self):
return f'{self.width:d}x{self.height:d}'
# a function to make sure a valid threshold is given in args
def Threshold(t):
t = int(t)
if not (0 <= t <= 256):
raise argparse.ArgumentTypeError(f"{t:d} is not in range 0 to 256")
return t
# a function to make sure a valid bayer_scale is given in args
def BayerScale(s):
s = int(s)
if not (0 <= s <= 5):
raise argparse.ArgumentTypeError(f"{s:d} is not in range 0 to 5")
return s
# a function to make sure a valid compress factor is given in args
def CompressFactor(c):
c = float(c)
if c != 0 and not (1.0 <= c <= 30.0):
raise argparse.ArgumentTypeError(f"{c:g} is not 0 (disabled) or in range 1 to 30")
return c
# a function to make sure a valid highpass frequency is given in args
def HighpassFrequency(f):
f = int(f)
if f != 0 and not (20 <= f <= 2000):
raise argparse.ArgumentTypeError(f"{f:d} is not 0 (disabled) or in range 20 to 2000")
return f
# python uses half round even, but we need to use half round up
# in order to get an accurate frame count, because that's what
# ffmpeg uses when changing frame rates
def half_round_up(fraction):
if (fraction % 1 < Fraction(1, 2)):
return math.floor(fraction)
return math.ceil(fraction)
# setup the argument parser
parser = argparse.ArgumentParser(
description="A utility to convert videos to a format playable on the Flipper Zero.")
parser_exclusive_1 = parser.add_mutually_exclusive_group()
parser.add_argument('source',
type=Path,
help="the source file; must contain a video and audio stream")
parser.add_argument('output',
type=Path,
help="the resulting bundle")
parser_exclusive_1.add_argument('-d', '--dither',
choices=["bayer",
"heckbert",
"floyd_steinberg",
"sierra2",
"sierra2_4a",
"sierra3",
"burkes",
"atkinson",
"none"],
default="sierra3",
metavar='ALGORITHM',
help="the dithering algorithm to use, or 'none' to disable; for a list of options, see FFmpeg's 'paletteuse'; defaults to 'sierra3'")
parser.add_argument('--bayer-scale',
type=BayerScale,
dest='bayer_scale',
help="used with '-d/--dither bayer' to define the scale of the pattern (how much crosshatch is visible), from 0 to 5; defaults to '2'")
parser_exclusive_1.add_argument('-t', '--threshold',
type=Threshold,
help="the threshold to apply when converting to black and white, from 0 to 256; cannot be used with dithering")
parser.add_argument('-f', '--frame-rate',
type=Fraction,
dest='frame_rate',
help="the desired video frame rate, may be a fraction; defaults to source frame rate")
parser.add_argument('-s', '--scale',
type=VideoScale,
dest='scale',
help=f"the desired video size, cannot be larger than {SCREEN_WIDTH:d}x{SCREEN_HEIGHT:d}; default best fit")
parser.add_argument('-r', '--sample-rate',
type=int,
dest='sample_rate',
help="the desired audio sample rate; defaults to the source sample rate")
parser.add_argument('-n', '--normalize',
choices=['dynamic', 'loudness', 'peak', 'none'],
default='dynamic',
help="how to normalize the audio volume: 'dynamic' continuously "
"pushes every passage close to full scale (loudest, best for "
"the flipper's speaker), 'loudness' does a two-pass EBU R128 "
"normalization, 'peak' does a two-pass peak normalization "
"(quietest, preserves dynamics), 'none' leaves the volume "
"alone; defaults to 'dynamic'")
parser.add_argument('-l', '--level',
type=float,
dest='level',
help=f"the target level for '-n/--normalize loudness' (in LUFS, "
f"defaults to {DEFAULT_LOUDNESS_LEVEL:g}) or '-n/--normalize "
f"peak' (in dBFS, defaults to {DEFAULT_PEAK_LEVEL:g}); ignored "
f"by the other modes")
parser.add_argument('-c', '--compress',
type=CompressFactor,
dest='compress',
default=DEFAULT_COMPRESS,
help=f"how hard to compress the dynamic range before normalizing, "
f"from 1 to 30 where lower means more compression, or 0 to "
f"disable; only used by '-n/--normalize dynamic'; defaults to "
f"'{DEFAULT_COMPRESS:g}'")
parser.add_argument('--highpass',
type=HighpassFrequency,
dest='highpass',
default=DEFAULT_HIGHPASS,
help=f"roll off audio below this frequency in Hz before normalizing; "
f"the flipper's speaker cannot reproduce it anyway and removing "
f"it leaves more headroom for what you can hear; 0 disables; "
f"defaults to '{DEFAULT_HIGHPASS:d}'")
parser.add_argument('-g', '--gain',
type=float,
dest='gain',
default=0.0,
help="extra gain in dB applied after normalization; the safety "
"limiter keeps it from clipping; defaults to '0'")
parser.add_argument('--ffmpeg',
dest='ffmpeg_cmd',
default=os.environ.get('FFMPEG_BINARY', 'ffmpeg'),
metavar='PATH',
help="the ffmpeg executable to use; defaults to the FFMPEG_BINARY "
"environment variable, or 'ffmpeg' from PATH")
parser.add_argument('--ffprobe',
dest='ffprobe_cmd',
default=os.environ.get('FFPROBE_BINARY', 'ffprobe'),
metavar='PATH',
help="the ffprobe executable to use; defaults to the FFPROBE_BINARY "
"environment variable, or 'ffprobe' from PATH")
parser.add_argument('-q', '--quiet',
action='count',
default=0,
help="don't output info to stdout, use twice to silence warnings")
args = parser.parse_args()
# ffmpeg and ffprobe are separate programs, and starting either of them can
# fail for reasons that have nothing to do with this script; explain what
# happened instead of dumping a traceback
def exit_launch_error(error, command):
if isinstance(error, FileNotFoundError):
message = textwrap.dedent(f'''\
could not find '{command}'
Install FFmpeg and make sure both 'ffmpeg' and 'ffprobe' are on your
PATH, or point this script at them directly:
converter.py ... --ffmpeg /path/to/ffmpeg --ffprobe /path/to/ffprobe''')
elif getattr(error, 'winerror', None) in (1260, 4551):
message = textwrap.dedent(f'''\
Windows blocked '{command}' from starting ({error.strerror})
This is Smart App Control (or another Application Control policy)
refusing to run the FFmpeg binary itself; it is not something this
script is doing, and no script can override that policy. Options:
* install a different FFmpeg build and point at it with
--ffmpeg/--ffprobe (the policy decides per binary, so another
build may run fine)
* run the conversion inside WSL, where the policy does not apply
* turn Smart App Control off (note: it cannot be turned back on
without reinstalling Windows)
See the README for details.''')
else:
message = f"could not run '{command}': {error}"
print(f"error: {message}", file=sys.stderr)
sys.exit(1)
# start an ffmpeg process that we read raw data back from
def start_process(stream):
try:
return stream.run_async(cmd=args.ffmpeg_cmd, pipe_stdout=True)
except OSError as error:
exit_launch_error(error, args.ffmpeg_cmd)
# only allow '--bayer-scale` to be used with bayer dithering
if args.bayer_scale != None and args.dither != 'bayer':
parser.error("--bayer-scale can only be used with '-d/--dither bayer'")
# only allow '--level' to be used with the modes that have a target level
if args.level != None and args.normalize not in ('loudness', 'peak'):
parser.error("-l/--level can only be used with '-n/--normalize loudness' or '-n/--normalize peak'")
# fill in the default target level for the selected mode
if args.normalize == 'loudness':
level = DEFAULT_LOUDNESS_LEVEL if args.level == None else args.level
if not (-70.0 <= level <= -5.0):
parser.error(f"{level:g} LUFS is not in range -70 to -5")
elif args.normalize == 'peak':
level = DEFAULT_PEAK_LEVEL if args.level == None else args.level
if level > 0.0:
parser.error(f"{level:g} dBFS is above full scale")
else:
level = None
# get media information
video_index = None
audio_index = None
try:
ffprobe_result = ffmpeg.probe(args.source, cmd=args.ffprobe_cmd, count_packets=None)
except OSError as error:
exit_launch_error(error, args.ffprobe_cmd)
for stream in ffprobe_result['streams']:
if stream['disposition']['default']:
if stream['codec_type'] == 'video' and video_index == None:
source_frame_count = int(stream['nb_read_packets'])
source_width = int(stream['width'])
source_height = int(stream['height'])
source_frame_rate = Fraction(stream['r_frame_rate'])
video_index = stream['index']
if stream['codec_type'] == 'audio' and audio_index == None:
source_sample_rate = int(stream['sample_rate'])
audio_index = stream['index']
# display an error if video or audio is missing
if video_index == None:
parser.error("source file does not contain a default video stream")
if audio_index == None:
parser.error("source file does not contain a default audio stream")
# get the video dimensions before padding
if args.scale == None:
# default: maintain aspect ratio and scale to fit screen
scale_factor = max(source_width / SCREEN_WIDTH, source_height / SCREEN_HEIGHT)
pre_pad_width = math.floor(source_width / scale_factor)
frame_height = math.floor(source_height / scale_factor)
else:
# user defined dimensions
pre_pad_width = args.scale.width
frame_height = args.scale.height
# get width after padding and final frame size
frame_width = pre_pad_width + 8 - (pre_pad_width % 8)
frame_size = int(frame_width * frame_height / 8)
# determine sample and frame rates
sample_rate = args.sample_rate or source_sample_rate
frame_rate = args.frame_rate or source_frame_rate
# calculate new frame count
frame_count = half_round_up(source_frame_count * frame_rate / source_frame_rate)
# calculate audio chunk size
audio_chunk_size = (source_frame_count * sample_rate) / (source_frame_rate * frame_count)
# used to calculate which samples to drop to prevent desync
audio_sample_drop_rate = audio_chunk_size % 1
audio_chunk_size = int(audio_chunk_size)
# estimate the final size, used later to check for errors
estimated_file_size = int(
(((frame_width * frame_height) / 8) + audio_chunk_size)
* frame_count + len(BUNDLE_SIGNATURE) + 11)
# describe the audio processing for the info output
if args.normalize == 'dynamic':
normalization_info = 'dynamic (per-passage)'
elif args.normalize == 'loudness':
normalization_info = f'loudness, {level:g} LUFS'
elif args.normalize == 'peak':
normalization_info = f'peak, {level:g} dBFS'
else:
normalization_info = 'none'
if args.normalize != 'none':
if args.highpass > 0:
normalization_info += f', high-pass {args.highpass:d} Hz'
if args.normalize == 'dynamic' and args.compress > 0:
normalization_info += f', compress {args.compress:g}'
if args.gain != 0:
normalization_info += f', {args.gain:+g} dB'
# print final bundle info
if args.quiet < 1:
print(textwrap.dedent(f'''\
Frame rate: {float(frame_rate):g} fps
Frame count: {frame_count:d} frames
Video scale (before padding): {pre_pad_width:d}x{frame_height:d}
Video scale (after padding): {frame_width:d}x{frame_height:d}
Audio sample rate: {sample_rate:d} Hz
Audio chunk size: {audio_chunk_size:d} bytes
Audio normalization: {normalization_info}
Estimated file size: {estimated_file_size:n} bytes
'''))
if frame_count > source_frame_count:
print(f"{frame_count - source_frame_count:d} frames will be duplicated\n")
if frame_count < source_frame_count:
print(f"{source_frame_count - frame_count:d} frames will be dropped\n")
if args.quiet < 2:
if frame_rate > 30:
print("warning: frame rate is greater than maximum recommended 30 fps\n")
if sample_rate > 48000:
print("warning: sample rate is greater than maximum recommended 48 kHz\n")
# the head of the audio filter chain, shared by the analysis passes and the
# actual conversion; everything is measured and processed on the exact signal
# the flipper will play back: mono, at the final sample rate
def audio_chain_head():
stream = (
ffmpeg
.input(args.source)[str(audio_index)]
.filter('aformat',
sample_fmts='fltp',
channel_layouts='mono',
sample_rates=str(sample_rate))
)
# drop the low end the speaker cannot reproduce; it only eats headroom
if args.highpass > 0:
stream = stream.filter('highpass', frequency=args.highpass, poles=2)
return stream
# run an analysis pass over the audio and return ffmpeg's stderr output
def analyze_audio(stream, description):
if args.quiet < 1:
print(f"Analyzing audio ({description})...\n")
try:
_, stderr = (
stream
.output('-', format='null')
.global_args('-v', 'info', '-nostats')
.run(cmd=args.ffmpeg_cmd, capture_stdout=True, capture_stderr=True)
)
except OSError as error:
exit_launch_error(error, args.ffmpeg_cmd)
except ffmpeg.Error as error:
stderr = error.stderr
if args.quiet < 2:
print("warning: audio analysis failed, falling back to no normalization\n")
print(stderr.decode('utf8', 'replace'), file=sys.stderr)
return None
return stderr.decode('utf8', 'replace')
# pull 'max_volume' and 'mean_volume' (both dBFS) out of a volumedetect pass
def measure_volume():
stderr = analyze_audio(audio_chain_head().filter('volumedetect'), 'peak level')
if stderr == None:
return None, None
max_volume = None
mean_volume = None
for match in re.finditer(r'(max|mean)_volume:\s*(-?\d+(?:\.\d+)?|-inf)\s*dB', stderr):
value = -math.inf if match.group(2) == '-inf' else float(match.group(2))
if match.group(1) == 'max':
max_volume = value
else:
mean_volume = value
return max_volume, mean_volume
# work out the static gain needed to bring the peak up to 'target' dBFS,
# or None if the audio is silent or the measurement failed
def peak_gain_to(target):
max_volume, mean_volume = measure_volume()
if max_volume == None:
return None
if not math.isfinite(max_volume) or max_volume < SILENCE_LEVEL:
if args.quiet < 2:
print("warning: audio is silent or nearly silent, skipping normalization\n")
return None
# don't blow up the noise floor of a nearly silent recording
gain = min(target - max_volume, MAX_STATIC_GAIN)
if args.quiet < 1:
print(f"Measured peak: {max_volume:g} dBFS, "
f"mean {mean_volume:g} dBFS "
f"({gain:+g} dB applied)\n")
if args.quiet < 2 and gain < target - max_volume:
print(f"warning: gain limited to {MAX_STATIC_GAIN:+g} dB to avoid amplifying noise\n")
return gain
# run loudnorm's measurement pass and return its JSON report
def measure_loudness():
stream = audio_chain_head().filter('loudnorm',
i=level,
lra=LOUDNESS_RANGE,
tp=DEFAULT_PEAK_LEVEL,
print_format='json')
stderr = analyze_audio(stream, 'EBU R128 loudness')
if stderr == None:
return None
try:
report = json.loads(stderr[stderr.rindex('{'):stderr.rindex('}') + 1])
# a silent or near silent stream measures as -inf and cannot be used
measured = {key: float(value) for key, value in report.items()
if key.startswith(('input_', 'target_'))}
except (ValueError, KeyError):
if args.quiet < 2:
print("warning: could not read loudness measurements, falling back to no normalization\n")
return None
if (not all(map(math.isfinite, measured.values()))
or measured['input_i'] < SILENCE_LEVEL):
if args.quiet < 2:
print("warning: audio is silent or nearly silent, skipping normalization\n")
return None
return measured
# open the output file for writing
output = open(args.output, 'wb')
# specify the input file
input = ffmpeg.input(args.source)
# build the audio filter chain
audio_input = audio_chain_head()
normalized = args.normalize != 'none'
if args.normalize == 'dynamic':
# first bring the whole stream up to just below full scale, so that the
# dynamic normalizer below doesn't have to spend its (deliberately
# limited) local gain on making up for a quiet recording
peak_gain = peak_gain_to(DEFAULT_PEAK_LEVEL)
if peak_gain != None:
audio_input = audio_input.filter('volume', f'{peak_gain:f}dB')
# then continuously normalize each passage of the audio to just below
# full scale, so quiet dialogue is as loud as the rest of the video
dynaudnorm_args = {
'framelen': 150,
'gausssize': 15,
'peak': LIMITER_CEILING,
'maxgain': DEFAULT_MAX_GAIN,
}
if args.compress > 0:
dynaudnorm_args['compress'] = args.compress
audio_input = audio_input.filter('dynaudnorm', **dynaudnorm_args)
elif args.normalize == 'loudness':
measured = measure_loudness()
if measured == None:
normalized = False
else:
# don't blow up the noise floor of a nearly silent recording
target = min(level, measured['input_i'] + MAX_STATIC_GAIN)
if args.quiet < 1:
print(f"Measured loudness: {measured['input_i']:g} LUFS, "
f"true peak {measured['input_tp']:g} dBTP "
f"(target {target:g} LUFS)\n")
if args.quiet < 2 and target < level:
print(f"warning: gain limited to {MAX_STATIC_GAIN:+g} dB to avoid amplifying noise\n")
audio_input = audio_input.filter('loudnorm',
i=target,
lra=LOUDNESS_RANGE,
tp=DEFAULT_PEAK_LEVEL,
measured_i=measured['input_i'],
measured_lra=measured['input_lra'],
measured_tp=measured['input_tp'],
measured_thresh=measured['input_thresh'],
offset=measured['target_offset'])
# loudnorm resamples to 192 kHz internally, undo that
audio_input = audio_input.filter('aformat',
sample_fmts='fltp',
sample_rates=str(sample_rate))
elif args.normalize == 'peak':
peak_gain = peak_gain_to(level)
if peak_gain == None:
normalized = False
else:
audio_input = audio_input.filter('volume', f'{peak_gain:f}dB')
if normalized:
# any extra gain the user asked for
if args.gain != 0:
audio_input = audio_input.filter('volume', f'{args.gain:f}dB')
# a safety limiter so nothing ever clips into the 8-bit output
audio_input = audio_input.filter('alimiter',
limit=LIMITER_CEILING,
attack=5,
release=50,
level='disabled')
else:
# normalization was disabled or failed; use the untouched audio stream
audio_input = input[str(audio_index)]
audio_process = start_process(
audio_input
# output raw 8-bit audio
.output('pipe:',
format='u8',
acodec='pcm_u8',
ac=1,
ar=sample_rate)
# only display errors
.global_args('-v', 'error')
)
scaled_video = (
input[str(video_index)]
# scale the video
.filter('scale', pre_pad_width, frame_height)
# convert to grayscale
.filter('format', 'gray')
# set the frame rate
.filter('fps', frame_rate)
)
if args.threshold != None:
# convert to black and white with threshold
video_input = scaled_video.filter('maskfun',
low=args.threshold - 1,
high=args.threshold - 1,
sum=256,
fill=255)
else:
# the palette used for dithering
palette = ffmpeg.filter([
ffmpeg.input('color=c=black:r=1:d=1:s=8x16', f='lavfi'),
ffmpeg.input('color=c=white:r=1:d=1:s=8x16', f='lavfi')
], 'hstack', 2)
# convert to black and white with dithering
if (args.dither == 'bayer' and args.bayer_scale != None):
# if a bayer_scale was provided
video_input = ffmpeg.filter([scaled_video, palette],
'paletteuse',
new='true',
dither=args.dither,
bayer_scale=args.bayer_scale)
else:
video_input = ffmpeg.filter([scaled_video, palette],
'paletteuse',
new='true',
dither=args.dither)
video_process = start_process(
video_input
# pad the width to make sure it is a multiple of 8
.filter('pad', frame_width, frame_height, -1, 0, 'white')
# output raw video data, one bit per pixel, inverted, and
# disable dithering (we've already handled it)
.output('pipe:',
sws_dither='none',
format='rawvideo',
pix_fmt='monow')
# only display errors
.global_args('-v', 'error')
)
# header format:
# signature (char[7] / 7s): "BND!VID"
# version (uint8 / B): 1
# frame_count (uint32 / I)
# audio_chunk_size (uint16 / H): sample_rate / frame_rate
# sample_rate (uint16 / H)
# frame_height (uint8 / B)
# frame_width (uint8 / B)
header = struct.pack(f'<{len(BUNDLE_SIGNATURE):d}sBIHHBB',
BUNDLE_SIGNATURE.encode('utf8'),
BUNDLE_VERSION,
frame_count,
audio_chunk_size,
sample_rate,
frame_height,
frame_width)
# write the header to the file
output.write(header)
bytes_written = len(header)
# the number of audio samples that need to be dropped
drop_samples = audio_sample_drop_rate
dropped_samples = 0
for frame_num in range(1, frame_count + 1):
# print current progress every 10 seconds of video
if args.quiet < 1 and (
frame_num % math.floor(frame_rate * 10) == 0 or
frame_num == 1 or
frame_num == frame_count):
print(f"Processing frame {frame_num:>{len(str(frame_count))}d} / {frame_count:d}: {frame_num / frame_count:>7.2%}")
# read a single frame and audio chunk
frame = video_process.stdout.read(frame_size)
audio_chunk = audio_process.stdout.read(audio_chunk_size)
# reverse the bit-order of each byte in the frame
frame_data = bytearray()
for byte in frame:
frame_data.append(int(f'{byte:08b}'[::-1], 2))
# calculate and drop samples; prevents desync
drop_samples += audio_sample_drop_rate
audio_process.stdout.read(int(drop_samples))
dropped_samples += int(drop_samples)
drop_samples %= 1
# write frame and audio data
output.write(frame_data)
output.write(audio_chunk)
bytes_written += len(frame_data) + len(audio_chunk)
# close the file descriptor
output.close()
# wait for ffmpeg processes to finish
video_process.wait()
audio_process.wait()
if args.quiet < 1:
print()
if dropped_samples > 0:
print(f"{dropped_samples:n} audio samples were dropped to prevent desync\n")
print(f"{bytes_written:n} bytes written to {args.output}\n")
if args.quiet < 2:
if bytes_written != estimated_file_size:
print(f"warning: number of bytes written does not match estimated file size, something may have gone wrong\n")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment