ffx/ffx.sh
2026-09-09 02:41:51 +00:00

748 lines
12 KiB
Bash

#!/usr/bin/env bash
set -u
P=${0##*/}
V=1.2.0
CFG=${FFX_CONFIG_FILE:-"$HOME/.config/ffx/helpers.sh"}
OS=unix
case "$(uname -s 2>/dev/null || printf unknown)" in
MINGW*|MSYS*|CYGWIN*) OS=windows ;;
esac
case "${NO_COLOR:-}" in
1|true|yes) R= G= Y= B= D= Z= ;;
*)
if [ -t 1 ]; then
R=$(printf '\033[31m')
G=$(printf '\033[32m')
Y=$(printf '\033[33m')
B=$(printf '\033[34m')
D=$(printf '\033[1m')
Z=$(printf '\033[0m')
else
R= G= Y= B= D= Z=
fi
;;
esac
i(){ printf '%s%s→%s %s\n' "$B" "$D" "$Z" "$*"; }
o(){ printf '%s%s✓%s %s\n' "$G" "$D" "$Z" "$*"; }
w(){ printf '%s%s!%s %s\n' "$Y" "$D" "$Z" "$*" >&2; }
e(){ printf '%s%s✗%s %s\n' "$R" "$D" "$Z" "$*" >&2; }
x(){ e "$*"; exit 1; }
h(){ command -v "$1" >/dev/null 2>&1; }
n(){ printf '%s' "$1" | grep -Eq '^[+-]?([0-9]+([.][0-9]*)?|[.][0-9]+)$'; }
usage(){
cat <<EOF
$P $V
Usage:
$P <command> [arguments]
$P --add-helper-cmd
$P --remove-helper-cmd
$P -h|--help
$P <command> -h|--help
Commands:
bass INPUT PERCENT [FINE_DB] [OUTPUT]
treble INPUT PERCENT [FINE_DB] [OUTPUT]
volume INPUT DB [OUTPUT]
distort INPUT [DRIVE_0_TO_10] [OUTPUT]
trim INPUT START_SECONDS [END_SECONDS] [OUTPUT]
tempo INPUT SPEED_0.125_TO_8 [OUTPUT]
pitch INPUT SEMITONES_-36_TO_36 [OUTPUT]
install
help
Options:
--add-helper-cmd Install short shell helper commands.
--remove-helper-cmd Remove installed helper commands.
--no-color Disable colored output.
-h, --help Show help.
-V, --version Show version.
Examples:
$P bass song.mp3 25 1 bass.mp3
$P tempo song.mp3 1.5 fast.mp3
$P trim song.wav 10 30 clip.wav
EOF
}
chelp(){
case "$1" in
bass|treble)
cat <<EOF
Usage:
$P $1 INPUT PERCENT [FINE_DB] [OUTPUT]
PERCENT range: -200..200
FINE_DB range: -60..60
33 percent = 16.5 dB
66 percent = 33 dB
100 percent = 50 dB
EOF
;;
volume)
cat <<EOF
Usage:
$P volume INPUT DB [OUTPUT]
DB range: -120..120
EOF
;;
distort)
cat <<EOF
Usage:
$P distort INPUT [DRIVE_0_TO_10] [OUTPUT]
DRIVE range: 0..10
EOF
;;
trim)
cat <<EOF
Usage:
$P trim INPUT START_SECONDS [END_SECONDS] [OUTPUT]
EOF
;;
tempo)
cat <<EOF
Usage:
$P tempo INPUT SPEED_0.125_TO_8 [OUTPUT]
Changes speed while preserving pitch.
EOF
;;
pitch)
cat <<EOF
Usage:
$P pitch INPUT SEMITONES_-36_TO_36 [OUTPUT]
Changes pitch while preserving duration.
EOF
;;
*)
usage
;;
esac
}
home(){
case "$1" in
"~/"*) printf '%s/%s\n' "$HOME" "${1#~/}" ;;
*) printf '%s\n' "$1" ;;
esac
}
dir(){
case "$1" in
*/*) printf '%s\n' "${1%/*}" ;;
*\\*) printf '%s\n' "${1%\\*}" ;;
*) printf '.\n' ;;
esac
}
base(){ printf '%s\n' "${1##*[\\/]}" ;}
stem(){
local a
a=$(base "$1")
case "$a" in
*.*) printf '%s\n' "${a%.*}" ;;
*) printf '%s\n' "$a" ;;
esac
}
ext(){
local a
a=$(base "$1")
case "$a" in
*.*) printf '%s\n' "${a##*.}" ;;
*) printf '\n' ;;
esac
}
rid(){
if [ -r /dev/urandom ] && h od; then
od -An -N5 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n'
else
printf '%s%s%s' "$$" "${RANDOM:-0}" "$(date +%s)"
fi
}
out(){
local a=$1 t=$2 d s e
d=$(dir "$a")
s=$(stem "$a")
e=$(ext "$a")
[ -n "$e" ] || e=wav
printf '%s/%s_%s_%s.%s\n' "$d" "$s" "$t" "$(rid)" "$e"
}
abs(){
if h realpath; then
realpath -m -- "$1" 2>/dev/null
else
local d b
d=$(dir "$1")
b=$(base "$1")
(
cd -P -- "$d" 2>/dev/null &&
printf '%s/%s\n' "$(pwd)" "$b"
)
fi
}
same(){
[ "$(abs "$1")" = "$(abs "$2")" ]
}
probe(){
h ffprobe || return 0
ffprobe -v error \
-select_streams a:0 \
-show_entries stream=codec_type \
-of default=noprint_wrappers=1:nokey=1 \
-- "$1" 2>/dev/null | grep -qx audio
}
pm(){
if [ "$OS" = windows ]; then
h winget && { printf '%s' winget; return; }
h choco && { printf '%s' choco; return; }
h scoop && { printf '%s' scoop; return; }
fi
for a in apt-get dnf pacman zypper apk; do
h "$a" && { printf '%s' "$a"; return; }
done
}
install(){
h ffmpeg && {
o "ffmpeg is already installed."
return
}
local m
m=$(pm)
[ -n "$m" ] ||
x "No supported package manager found. Install ffmpeg manually."
i "Installing ffmpeg."
case "$m" in
apt-get)
sudo apt-get update &&
sudo apt-get install -y ffmpeg
;;
dnf)
sudo dnf install -y ffmpeg
;;
pacman)
sudo pacman -Sy --noconfirm ffmpeg
;;
zypper)
sudo zypper --non-interactive install ffmpeg
;;
apk)
sudo apk add ffmpeg
;;
winget)
winget install --id Gyan.FFmpeg -e
;;
choco)
choco install ffmpeg -y
;;
scoop)
scoop install ffmpeg
;;
esac || x "ffmpeg installation failed."
h ffmpeg || x "ffmpeg is still unavailable."
}
req(){
h awk || x "awk is required."
h ffmpeg && return
if [ -t 0 ]; then
printf 'ffmpeg is missing. Install now? [Y/n] '
read -r a
case "${a:-Y}" in
y|Y|yes|YES) install ;;
*) exit 1 ;;
esac
else
x "ffmpeg is missing. Run: $P install"
fi
}
prep(){
local a=$1 d
d=$(dir "$a")
[ -d "$d" ] ||
mkdir -p -- "$d" ||
x "Cannot create directory: $d"
[ -w "$d" ] ||
x "Directory is not writable: $d"
[ -e "$a" ] || return
if [ -t 0 ]; then
printf '%sOutput exists: %s. Overwrite? [y/N] %s' \
"$Y" "$a" "$Z" >&2
read -r q
case "$q" in
y|Y|yes|YES) ;;
*) x "Output was not overwritten." ;;
esac
else
x "Output exists; refusing overwrite: $a"
fi
}
run(){
local in=$1 f=$2 op=$3 d s e tmp log rc
local -a c
req
in=$(home "$in")
op=$(home "$op")
[ -f "$in" ] &&
[ -r "$in" ] ||
x "Input is missing or unreadable: $in"
[ -n "$f" ] ||
x "Missing audio filter."
same "$in" "$op" &&
x "Input and output must differ."
probe "$in" ||
x "No readable audio stream found: $in"
d=$(dir "$op")
s=$(stem "$op")
e=$(ext "$op")
[ -n "$e" ] ||
x "Output requires an extension."
case "$(printf '%s' "$e" | tr '[:upper:]' '[:lower:]')" in
mp3) c=(-c:a libmp3lame -q:a 2) ;;
wav) c=(-c:a pcm_s16le) ;;
flac) c=(-c:a flac) ;;
ogg) c=(-c:a libvorbis -q:a 6) ;;
opus) c=(-c:a libopus -b:a 192k) ;;
m4a) c=(-c:a aac -b:a 256k -movflags +faststart) ;;
aac) c=(-c:a aac -b:a 256k) ;;
mka) c=(-c:a flac) ;;
*) x "Unsupported output extension: .$e" ;;
esac
prep "$op"
tmp="$d/.${s}.ffx-partial.$$.$(rid).$e"
log="$tmp.log"
i "Processing: $(base "$in")"
i "Output: $op"
rm -f -- "$tmp" "$log"
ffmpeg \
-hide_banner \
-nostdin \
-y \
-v warning \
-i "$in" \
-map 0:a:0 \
-vn \
-af "$f" \
"${c[@]}" \
"$tmp" 2>"$log"
rc=$?
if [ "$rc" -eq 0 ] && [ -s "$tmp" ]; then
mv -f -- "$tmp" "$op" ||
x "Could not finalize output."
rm -f -- "$log"
o "Done: $op"
return
fi
[ -s "$log" ] && cat "$log" >&2
rm -f -- "$tmp" "$log"
x "Processing failed."
}
db(){
n "$1" &&
n "${2:-0}" ||
return 1
awk -v p="$1" -v f="${2:-0}" '
BEGIN {
if (p < -200 || p > 200 || f < -60 || f > 60) exit 1
printf "%.2f", p * .5 + f
}'
}
tempo_filter(){
local r=$1 z c=
n "$r" || return 1
awk -v n="$r" \
'BEGIN { exit !(n >= .125 && n <= 8) }' ||
return 1
z=$r
while awk -v n="$z" 'BEGIN { exit !(n > 2) }'; do
c="${c}${c:+,}atempo=2"
z=$(awk -v n="$z" 'BEGIN { printf "%.12f", n / 2 }')
done
while awk -v n="$z" 'BEGIN { exit !(n < .5) }'; do
c="${c}${c:+,}atempo=0.5"
z=$(awk -v n="$z" 'BEGIN { printf "%.12f", n * 2 }')
done
printf '%s%satempo=%s\n' "$c" "${c:+,}" "$z"
}
boost(){
local t=$1 in p f=0 op g
shift
[ "$#" -ge 2 ] ||
x "Usage: $P $t INPUT PERCENT [FINE_DB] [OUTPUT]"
in=$1
p=$2
if [ "$#" -ge 3 ]; then
if n "$3"; then
f=$3
op=${4:-}
else
op=$3
fi
fi
g=$(db "$p" "$f") ||
x "PERCENT must be -200..200; FINE_DB must be -60..60."
run "$in" \
"$t=g=$g:f=$([ "$t" = bass ] && printf 110 || printf 4000):w=$([ "$t" = bass ] && printf .6 || printf .5)" \
"${op:-$(out "$in" "$t")}"
}
volume(){
local in=${1:-} d=${2:-} op=${3:-}
[ -n "$in" ] &&
[ -n "$d" ] ||
x "Usage: $P volume INPUT DB [OUTPUT]"
n "$d" &&
awk -v n="$d" \
'BEGIN { exit !(n >= -120 && n <= 120) }' ||
x "DB must be between -120 and 120."
run "$in" "volume=${d}dB" \
"${op:-$(out "$in" volume)}"
}
distort(){
local in=${1:-} d=3 op=pg
[ -n "$in" ] ||
x "Usage: $P distort INPUT [DRIVE_0_TO_10] [OUTPUT]"
if [ "$#" -ge 2 ]; then
if n "$2"; then
d=$2
op=${3:-}
else
op=$2
fi
fi
n "$d" &&
awk -v n="$d" \
'BEGIN { exit !(n >= 0 && n <= 10) }' ||
x "DRIVE must be between 0 and 10."
pg=$(awk -v d="$d" \
'BEGIN { printf "%.3f", 1 + d * .85 }')
run "$in" \
"volume=$pg,alimiter=limit=.82:attack=5:release=50:level=disabled" \
"${op:-$(out "$in" distort)}"
}
trim(){
local in=${1:-} a=${2:-} b= op f
[ -n "$in" ] &&
[ -n "$a" ] ||
x "Usage: $P trim INPUT START_SECONDS [END_SECONDS] [OUTPUT]"
n "$a" &&
awk -v n="$a" \
'BEGIN { exit !(n >= 0) }' ||
x "START_SECONDS must be zero or greater."
if [ "$#" -ge 3 ]; then
if n "$3"; then
b=$3
op=${4:-}
else
op=$3
fi
fi
if [ -n "$b" ]; then
awk -v a="$a" -v b="$b" \
'BEGIN { exit !(b > a) }' ||
x "END_SECONDS must be greater than START_SECONDS."
f="atrim=start=$a:end=$b,asetpts=PTS-STARTPTS"
else
f="atrim=start=$a,asetpts=PTS-STARTPTS"
fi
run "$in" "$f" "${op:-$(out "$in" trim)}"
}
speed(){
local in=${1:-} v=${2:-} op=${3:-} f
[ -n "$in" ] &&
[ -n "$v" ] ||
x "Usage: $P tempo INPUT SPEED_0.125_TO_8 [OUTPUT]"
f=$(tempo_filter "$v") ||
x "SPEED must be between 0.125 and 8."
run "$in" "$f" "${op:-$(out "$in" tempo)}"
}
pitch(){
local in=${1:-} s=${2:-} op=${3:-} r q f
[ -n "$in" ] &&
[ -n "$s" ] ||
x "Usage: $P pitch INPUT SEMITONES_-36_TO_36 [OUTPUT]"
n "$s" &&
awk -v n="$s" \
'BEGIN { exit !(n >= -36 && n <= 36) }' ||
x "SEMITONES must be between -36 and 36."
r=$(awk -v s="$s" \
'BEGIN { printf "%.12f", 2 ^ (s / 12) }')
q=$(awk -v r="$r" \
'BEGIN { printf "%.12f", 1 / r }')
f=$(tempo_filter "$q") ||
x "Cannot safely compensate duration."
run "$in" \
"asetrate=44100*$r,aresample=44100,$f" \
"${op:-$(out "$in" pitch)}"
}
block(){
cat <<EOF
# ffx helper commands
ffbass(){ $P bass "\$@"; }
fftreble(){ $P treble "\$@"; }
ffvolume(){ $P volume "\$@"; }
ffdistort(){ $P distort "\$@"; }
fftrim(){ $P trim "\$@"; }
fftempo(){ $P tempo "\$@"; }
ffpitch(){ $P pitch "\$@"; }
EOF
}
shell_rc(){
case "${SHELL##*/}" in
zsh) printf '%s/.zshrc\n' "$HOME" ;;
bash) printf '%s/.bashrc\n' "$HOME" ;;
*) printf '%s/.profile\n' "$HOME" ;;
esac
}
add(){
local rc="$1" b
mkdir -p -- "$(dirname "$CFG")" ||
x "Cannot create configuration directory."
if [ -f "$CFG" ] &&
grep -Fqx 'ffbass(){' "$CFG" 2>/dev/null; then
o "Helper commands are already installed: $CFG"
else
b=$(block)
{
[ -f "$CFG" ] && cat "$CFG"
printf '\n%s\n' "$b"
} > "$CFG.$$" ||
x "Cannot write helper configuration."
mv -f -- "$CFG.$$" "$CFG" ||
x "Cannot install helper configuration."
fi
if [ ! -f "$rc" ] ||
! grep -Fq "$CFG" "$rc" 2>/dev/null; then
printf '\n[ -f "%s" ] && . "%s"\n' "$CFG" "$CFG" >> "$rc" ||
x "Cannot update $rc."
fi
o "Installed helper commands."
i "Open a new terminal or run: . \"$rc\""
}
remove(){
[ -f "$CFG" ] ||
x "Helper configuration does not exist: $CFG"
awk '
BEGIN { skip = 0 }
/^# ffx helper commands$/ { skip = 1; next }
skip && /^ffpitch\(\)\{/ { skip = 0; next }
!skip { print }
' "$CFG" > "$CFG.$$" ||
x "Cannot update helper configuration."
mv -f -- "$CFG.$$" "$CFG" ||
x "Cannot remove helper commands."
o "Removed helper commands from $CFG."
}
main(){
case "${1:-}" in
-h|--help|help)
if [ "$#" -gt 1 ]; then
chelp "$2"
else
usage
fi
;;
-V|--version)
printf '%s %s\n' "$P" "$V"
;;
--no-color)
NO_COLOR=1
R= G= Y= B= D= Z=
shift
main "$@"
;;
--add-helper-cmd)
add "$(shell_rc)"
;;
--remove-helper-cmd)
remove
;;
install)
install
;;
bass)
shift
if [ "${1:-}" = "-h" ] ||
[ "${1:-}" = "--help" ]; then
chelp bass
else
boost bass "$@"
fi
;;
treble)
shift
if [ "${1:-}" = "-h" ] ||
[ "${1:-}" = "--help" ]; then
chelp treble
else
boost treble "$@"
fi
;;
volume)
shift
if [ "${1:-}" = "-h" ] ||
[ "${1:-}" = "--help" ]; then
chelp volume
else
volume "$@"
fi
;;
distort)
shift
if [ "${1:-}" = "-h" ] ||
[ "${1:-}" = "--help" ]; then
chelp distort
else
distort "$@"
fi
;;
trim)
shift
if [ "${1:-}" = "-h" ] ||
[ "${1:-}" = "--help" ]; then
chelp trim
else
trim "$@"
fi
;;
tempo)
shift
if [ "${1:-}" = "-h" ] ||
[ "${1:-}" = "--help" ]; then
chelp tempo
else
speed "$@"
fi
;;
pitch)
shift
if [ "${1:-}" = "-h" ] ||
[ "${1:-}" = "--help" ]; then
chelp pitch
else
pitch "$@"
fi
;;
"")
usage
;;
*)
e "Unknown command: $1"
usage
exit 2
;;
esac
}
main "$@"