#!/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 "$@"