496 lines
11 KiB
Bash
Executable File
496 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -u
|
|
set -o pipefail
|
|
|
|
program="$(basename "$0")"
|
|
|
|
dry_run=0
|
|
keep_originals=0
|
|
keep_foreign_metadata=1
|
|
float_to_flac24=0
|
|
delete_float_originals=0
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage:
|
|
$program [options] <directory>
|
|
|
|
Recursively convert WAV files to FLAC.
|
|
|
|
Normal integer PCM WAV files:
|
|
- Converted with the reference flac encoder.
|
|
- Encoded to a temporary directory first.
|
|
- Verified with flac --verify.
|
|
- Moved into place only after successful encoding.
|
|
- Original WAV is deleted after successful placement unless --keep is set.
|
|
|
|
32-bit float WAV files, WAV format type 3:
|
|
- Skipped by default, because FLAC does not store floating-point samples.
|
|
- Use --float-to-flac24 to explicitly convert them to 24-bit integer FLAC.
|
|
- Float originals are kept by default even when --keep is not set.
|
|
- Use --delete-float-originals to delete float WAVs after successful 24-bit FLAC conversion.
|
|
|
|
Options:
|
|
-n, --dry, --dry-run
|
|
Show what would happen without writing, moving, converting, or deleting files.
|
|
|
|
-k, --keep
|
|
Keep original WAV files after successful conversion.
|
|
|
|
--float-to-flac24
|
|
Convert 32-bit float WAV files to 24-bit integer FLAC.
|
|
This is not mathematically lossless for arbitrary float WAVs.
|
|
|
|
--delete-float-originals
|
|
When used with --float-to-flac24, delete the original float WAV after
|
|
successful conversion. Without this option, float WAV originals are kept.
|
|
|
|
--no-foreign-metadata
|
|
Do not ask flac to preserve non-audio WAV chunks.
|
|
|
|
-h, --help
|
|
Show this help text.
|
|
|
|
Behavior:
|
|
- Finds *.wav case-insensitively.
|
|
- Skips a WAV if a same-stem .flac already exists in the same directory.
|
|
- Prunes its own temporary directories.
|
|
EOF
|
|
}
|
|
|
|
die() {
|
|
echo "ERROR: $*" >&2
|
|
exit 1
|
|
}
|
|
|
|
log() {
|
|
echo "$*"
|
|
}
|
|
|
|
args=()
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-n|--dry|--dry-run)
|
|
dry_run=1
|
|
shift
|
|
;;
|
|
-k|--keep)
|
|
keep_originals=1
|
|
shift
|
|
;;
|
|
--float-to-flac24)
|
|
float_to_flac24=1
|
|
shift
|
|
;;
|
|
--delete-float-originals)
|
|
delete_float_originals=1
|
|
shift
|
|
;;
|
|
--no-foreign-metadata)
|
|
keep_foreign_metadata=0
|
|
shift
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
--)
|
|
shift
|
|
while [[ $# -gt 0 ]]; do
|
|
args+=("$1")
|
|
shift
|
|
done
|
|
;;
|
|
-*)
|
|
die "Unknown option: $1"
|
|
;;
|
|
*)
|
|
args+=("$1")
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
|
|
[[ "${#args[@]}" -eq 1 ]] || {
|
|
usage >&2
|
|
exit 2
|
|
}
|
|
|
|
root="${args[0]}"
|
|
|
|
while [[ "$root" != "/" && "$root" == */ ]]; do
|
|
root="${root%/}"
|
|
done
|
|
|
|
[[ -e "$root" ]] || die "Path does not exist: $root"
|
|
[[ -d "$root" ]] || die "Path is not a directory: $root"
|
|
|
|
if [[ "$dry_run" -eq 0 ]]; then
|
|
command -v flac >/dev/null 2>&1 || die "'flac' command not found. Install the reference FLAC encoder first."
|
|
|
|
if [[ "$float_to_flac24" -eq 1 ]]; then
|
|
if ! command -v sox >/dev/null 2>&1 && ! command -v ffmpeg >/dev/null 2>&1; then
|
|
die "--float-to-flac24 requires either 'sox' or 'ffmpeg'."
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
lowercase() {
|
|
printf '%s' "$1" | tr '[:upper:]' '[:lower:]'
|
|
}
|
|
|
|
same_stem_flac_exists() {
|
|
local dir="$1"
|
|
local stem="$2"
|
|
local candidate name base ext ext_lower
|
|
|
|
while IFS= read -r -d '' candidate; do
|
|
name="${candidate##*/}"
|
|
base="${name%.*}"
|
|
ext="${name##*.}"
|
|
ext_lower="$(lowercase "$ext")"
|
|
|
|
if [[ "$base" == "$stem" && "$ext_lower" == "flac" ]]; then
|
|
return 0
|
|
fi
|
|
done < <(find "$dir" -maxdepth 1 -type f -print0)
|
|
|
|
return 1
|
|
}
|
|
|
|
wav_format_tag() {
|
|
local path="$1"
|
|
|
|
if ! command -v python3 >/dev/null 2>&1; then
|
|
printf 'unknown\n'
|
|
return 0
|
|
fi
|
|
|
|
python3 - "$path" <<'PY'
|
|
import struct
|
|
import sys
|
|
|
|
path = sys.argv[1]
|
|
|
|
try:
|
|
with open(path, "rb") as f:
|
|
header = f.read(12)
|
|
if len(header) < 12:
|
|
print("unknown")
|
|
raise SystemExit(0)
|
|
|
|
riff = header[0:4]
|
|
wave = header[8:12]
|
|
|
|
if riff not in (b"RIFF", b"RF64") or wave != b"WAVE":
|
|
print("unknown")
|
|
raise SystemExit(0)
|
|
|
|
while True:
|
|
chunk_header = f.read(8)
|
|
if len(chunk_header) < 8:
|
|
print("unknown")
|
|
raise SystemExit(0)
|
|
|
|
chunk_id, chunk_size = struct.unpack("<4sI", chunk_header)
|
|
|
|
if chunk_id == b"fmt ":
|
|
data = f.read(chunk_size)
|
|
if len(data) < 2:
|
|
print("unknown")
|
|
else:
|
|
tag = struct.unpack("<H", data[:2])[0]
|
|
print(tag)
|
|
raise SystemExit(0)
|
|
|
|
# RIFF chunks are word-aligned.
|
|
skip = chunk_size + (chunk_size % 2)
|
|
f.seek(skip, 1)
|
|
|
|
except Exception:
|
|
print("unknown")
|
|
PY
|
|
}
|
|
|
|
cleanup_tmpdir() {
|
|
local tmpdir="${1:-}"
|
|
|
|
if [[ -n "$tmpdir" && -d "$tmpdir" ]]; then
|
|
rm -rf -- "$tmpdir"
|
|
fi
|
|
}
|
|
|
|
convert_integer_wav_with_flac() {
|
|
local wav="$1"
|
|
local tmp_flac="$2"
|
|
local log_file="$3"
|
|
|
|
local flac_args=(
|
|
--silent
|
|
--best
|
|
--verify
|
|
--preserve-modtime
|
|
)
|
|
|
|
if [[ "$keep_foreign_metadata" -eq 1 ]]; then
|
|
flac_args+=(--keep-foreign-metadata-if-present)
|
|
fi
|
|
|
|
flac "${flac_args[@]}" -o "$tmp_flac" -- "$wav" >"$log_file" 2>&1
|
|
}
|
|
|
|
convert_float_wav_to_flac24() {
|
|
local wav="$1"
|
|
local tmp_flac="$2"
|
|
local log_file="$3"
|
|
|
|
if command -v sox >/dev/null 2>&1; then
|
|
sox "$wav" -t flac -e signed-integer -b 24 -C 8 "$tmp_flac" >"$log_file" 2>&1
|
|
return $?
|
|
fi
|
|
|
|
ffmpeg \
|
|
-hide_banner \
|
|
-nostdin \
|
|
-loglevel error \
|
|
-y \
|
|
-i "$wav" \
|
|
-map 0:a:0 \
|
|
-map_metadata 0 \
|
|
-vn \
|
|
-c:a flac \
|
|
-compression_level 12 \
|
|
-sample_fmt s32 \
|
|
-bits_per_raw_sample 24 \
|
|
"$tmp_flac" >"$log_file" 2>&1
|
|
}
|
|
|
|
converted=0
|
|
converted_float=0
|
|
skipped_existing=0
|
|
unsupported_float=0
|
|
failed=0
|
|
|
|
would_convert=0
|
|
would_convert_float=0
|
|
would_skip_existing=0
|
|
would_skip_float=0
|
|
|
|
log "Root: $root"
|
|
|
|
if [[ "$dry_run" -eq 1 ]]; then
|
|
log "Mode: dry run"
|
|
elif [[ "$keep_originals" -eq 1 ]]; then
|
|
log "Mode: convert and keep originals"
|
|
else
|
|
log "Mode: convert and delete integer WAV originals after successful conversion"
|
|
fi
|
|
|
|
if [[ "$float_to_flac24" -eq 1 ]]; then
|
|
log "Float WAV mode: convert to 24-bit FLAC"
|
|
if [[ "$delete_float_originals" -eq 1 ]]; then
|
|
log "Float WAV deletion: enabled"
|
|
else
|
|
log "Float WAV deletion: disabled; originals will be kept"
|
|
fi
|
|
else
|
|
log "Float WAV mode: skip"
|
|
fi
|
|
|
|
log
|
|
|
|
while IFS= read -r -d '' wav; do
|
|
dir="${wav%/*}"
|
|
filename="${wav##*/}"
|
|
stem="${filename%.*}"
|
|
target="$dir/$stem.flac"
|
|
|
|
if same_stem_flac_exists "$dir" "$stem"; then
|
|
log "SKIP: FLAC already exists for: $wav"
|
|
|
|
if [[ "$dry_run" -eq 1 ]]; then
|
|
((would_skip_existing++))
|
|
else
|
|
((skipped_existing++))
|
|
fi
|
|
|
|
continue
|
|
fi
|
|
|
|
tag="$(wav_format_tag "$wav")"
|
|
is_float_wav=0
|
|
|
|
if [[ "$tag" == "3" ]]; then
|
|
is_float_wav=1
|
|
fi
|
|
|
|
if [[ "$is_float_wav" -eq 1 && "$float_to_flac24" -eq 0 ]]; then
|
|
log "SKIP FLOAT: $wav"
|
|
log " REASON: WAV format type 3 is IEEE float; reference flac cannot encode it directly."
|
|
|
|
if [[ "$dry_run" -eq 1 ]]; then
|
|
((would_skip_float++))
|
|
else
|
|
((unsupported_float++))
|
|
fi
|
|
|
|
continue
|
|
fi
|
|
|
|
if [[ "$dry_run" -eq 1 ]]; then
|
|
if [[ "$is_float_wav" -eq 1 ]]; then
|
|
log "WOULD CONVERT FLOAT TO 24-BIT FLAC: $wav"
|
|
log " TO: $target"
|
|
|
|
if [[ "$delete_float_originals" -eq 1 && "$keep_originals" -eq 0 ]]; then
|
|
log " ACTION: would delete original float WAV after successful conversion"
|
|
else
|
|
log " ACTION: would keep original float WAV"
|
|
fi
|
|
|
|
((would_convert_float++))
|
|
else
|
|
log "WOULD CONVERT: $wav"
|
|
log " TO: $target"
|
|
|
|
if [[ "$keep_originals" -eq 1 ]]; then
|
|
log " ACTION: would keep original WAV"
|
|
else
|
|
log " ACTION: would delete original WAV after successful conversion"
|
|
fi
|
|
|
|
((would_convert++))
|
|
fi
|
|
|
|
continue
|
|
fi
|
|
|
|
tmpdir="$(mktemp -d "$dir/.wav2flac.tmp.XXXXXXXXXX")" || {
|
|
log "FAIL: Could not create temporary directory in: $dir" >&2
|
|
((failed++))
|
|
continue
|
|
}
|
|
|
|
tmp_flac="$tmpdir/$stem.flac"
|
|
encode_log="$tmpdir/encode.log"
|
|
|
|
if [[ "$is_float_wav" -eq 1 ]]; then
|
|
log "CONVERT FLOAT TO 24-BIT FLAC: $wav"
|
|
|
|
if ! convert_float_wav_to_flac24 "$wav" "$tmp_flac" "$encode_log"; then
|
|
log "FAIL: Float WAV conversion failed for: $wav" >&2
|
|
cat "$encode_log" >&2
|
|
cleanup_tmpdir "$tmpdir"
|
|
((failed++))
|
|
continue
|
|
fi
|
|
|
|
if ! flac --silent --test "$tmp_flac" >"$tmpdir/flac-test.log" 2>&1; then
|
|
log "FAIL: Created FLAC did not pass flac --test: $tmp_flac" >&2
|
|
cat "$tmpdir/flac-test.log" >&2
|
|
cleanup_tmpdir "$tmpdir"
|
|
((failed++))
|
|
continue
|
|
fi
|
|
else
|
|
log "CONVERT: $wav"
|
|
|
|
if ! convert_integer_wav_with_flac "$wav" "$tmp_flac" "$encode_log"; then
|
|
if grep -qi 'unsupported format type 3' "$encode_log"; then
|
|
log "SKIP FLOAT: $wav"
|
|
log " REASON: WAV format type 3 is IEEE float; reference flac cannot encode it directly."
|
|
cleanup_tmpdir "$tmpdir"
|
|
((unsupported_float++))
|
|
continue
|
|
fi
|
|
|
|
log "FAIL: Conversion failed for: $wav" >&2
|
|
cat "$encode_log" >&2
|
|
cleanup_tmpdir "$tmpdir"
|
|
((failed++))
|
|
continue
|
|
fi
|
|
fi
|
|
|
|
if [[ ! -s "$tmp_flac" ]]; then
|
|
log "FAIL: Encoded FLAC is missing or empty: $tmp_flac" >&2
|
|
cleanup_tmpdir "$tmpdir"
|
|
((failed++))
|
|
continue
|
|
fi
|
|
|
|
if same_stem_flac_exists "$dir" "$stem"; then
|
|
log "SKIP: FLAC appeared during conversion for: $wav" >&2
|
|
cleanup_tmpdir "$tmpdir"
|
|
((skipped_existing++))
|
|
continue
|
|
fi
|
|
|
|
if ! mv -- "$tmp_flac" "$target"; then
|
|
log "FAIL: Could not move temp FLAC into place for: $wav" >&2
|
|
cleanup_tmpdir "$tmpdir"
|
|
((failed++))
|
|
continue
|
|
fi
|
|
|
|
cleanup_tmpdir "$tmpdir"
|
|
|
|
should_delete_original=1
|
|
|
|
if [[ "$keep_originals" -eq 1 ]]; then
|
|
should_delete_original=0
|
|
fi
|
|
|
|
if [[ "$is_float_wav" -eq 1 && "$delete_float_originals" -eq 0 ]]; then
|
|
should_delete_original=0
|
|
fi
|
|
|
|
if [[ "$should_delete_original" -eq 1 ]]; then
|
|
if rm -- "$wav"; then
|
|
log "DONE: $target"
|
|
log "DELETED: $wav"
|
|
else
|
|
log "WARN: FLAC was created, but failed to delete original WAV: $wav" >&2
|
|
((failed++))
|
|
continue
|
|
fi
|
|
else
|
|
log "DONE: $target"
|
|
log "KEPT: $wav"
|
|
fi
|
|
|
|
if [[ "$is_float_wav" -eq 1 ]]; then
|
|
((converted_float++))
|
|
else
|
|
((converted++))
|
|
fi
|
|
|
|
done < <(
|
|
find "$root" \
|
|
-type d -name '.wav2flac.tmp.*' -prune -o \
|
|
-type f -iname '*.wav' -print0
|
|
)
|
|
|
|
log
|
|
log "Summary:"
|
|
|
|
if [[ "$dry_run" -eq 1 ]]; then
|
|
log " Would convert integer WAVs: $would_convert"
|
|
log " Would convert float WAVs: $would_convert_float"
|
|
log " Would skip existing FLACs: $would_skip_existing"
|
|
log " Would skip unsupported floats: $would_skip_float"
|
|
else
|
|
log " Converted integer WAVs: $converted"
|
|
log " Converted float WAVs to FLAC24: $converted_float"
|
|
log " Skipped existing FLACs: $skipped_existing"
|
|
log " Skipped unsupported float WAVs: $unsupported_float"
|
|
log " Failed: $failed"
|
|
fi
|
|
|
|
if [[ "$dry_run" -eq 0 && "$failed" -gt 0 ]]; then
|
|
exit 1
|
|
fi
|
|
|
|
exit 0
|