diff --git a/ffx.sh b/ffx.sh new file mode 100644 index 0000000..25d8418 --- /dev/null +++ b/ffx.sh @@ -0,0 +1,748 @@ +#!/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 < [arguments] + $P --add-helper-cmd + $P --remove-helper-cmd + $P -h|--help + $P -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 </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 </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 "$@" \ No newline at end of file