Add lossless M4A concatenation tool
Shell script to recombine individual story m4a files back into per-cassette files using ffmpeg concat demuxer (stream copy, no re-encoding). Generates chapter markers from input filenames and preserves album art. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3ba7b31148
commit
6209a087d7
@ -38,6 +38,13 @@ This document specifies the functional requirements for an SDL2 based media play
|
||||
|
||||
## 5. Changelog
|
||||
|
||||
### 2026-02-11 — Lossless M4A concatenation tool
|
||||
|
||||
- **New tool**: `tools/concat_cassette.sh` — losslessly concatenates multiple m4a files into a single file using the ffmpeg concat demuxer (`-c copy`, no re-encoding). Designed to recombine individual story files back into per-cassette files.
|
||||
- **Chapter markers**: Generates ffmpeg metadata with one chapter per input file, titled from the input filename (sans extension). Chapters enable navigation within the combined file.
|
||||
- **Album art preserved**: Attached pictures from the first input file carry through automatically via stream copy.
|
||||
- **Fast-start output**: Uses `-movflags +faststart` to place the moov atom at the front for better seeking.
|
||||
|
||||
### 2026-02-10 — Volume control and d-pad/keyboard navigation
|
||||
|
||||
- **Cursor-based navigation**: Replaced mouse input with a focus-highlight model. Arrow keys (Left/Right) and d-pad move a visible blue highlight between UI elements; Enter/A button activates the focused button. Designed for use on a handheld gaming device with no mouse.
|
||||
|
||||
107
tools/concat_cassette.sh
Executable file
107
tools/concat_cassette.sh
Executable file
@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# concat_cassette.sh — Losslessly concatenate m4a files into a single file
|
||||
# with chapter markers derived from input filenames.
|
||||
#
|
||||
# Usage:
|
||||
# ./tools/concat_cassette.sh -o "Cassette 1.m4a" story1.m4a story2.m4a story3.m4a
|
||||
#
|
||||
# Requires: ffmpeg, ffprobe
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 -o OUTPUT INPUT1 INPUT2 [INPUT3 ...]"
|
||||
echo ""
|
||||
echo "Losslessly concatenate m4a files into a single file with chapter markers."
|
||||
echo "Album art is preserved from the first input file."
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -o OUTPUT Output filename (must not already exist)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
die() {
|
||||
echo "Error: $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Parse arguments ---
|
||||
|
||||
output=""
|
||||
while getopts "o:h" opt; do
|
||||
case "$opt" in
|
||||
o) output="$OPTARG" ;;
|
||||
h) usage ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
shift $((OPTIND - 1))
|
||||
|
||||
[ -z "$output" ] && die "Missing -o OUTPUT"
|
||||
[ $# -lt 2 ] && die "Need at least 2 input files"
|
||||
[ -e "$output" ] && die "Output file already exists: $output"
|
||||
|
||||
for cmd in ffmpeg ffprobe; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || die "$cmd not found"
|
||||
done
|
||||
|
||||
inputs=("$@")
|
||||
for f in "${inputs[@]}"; do
|
||||
[ -f "$f" ] || die "Input file not found: $f"
|
||||
done
|
||||
|
||||
# --- Set up temp dir ---
|
||||
|
||||
tmpdir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
|
||||
concat_list="$tmpdir/concat.txt"
|
||||
metadata="$tmpdir/metadata.txt"
|
||||
|
||||
# --- Build concat list and probe durations ---
|
||||
|
||||
echo ";FFMETADATA1" > "$metadata"
|
||||
|
||||
cumulative_ms=0
|
||||
for f in "${inputs[@]}"; do
|
||||
# Concat list entry (absolute path for -safe 0)
|
||||
abs=$(cd "$(dirname "$f")" && pwd)/$(basename "$f")
|
||||
printf "file '%s'\n" "$abs" >> "$concat_list"
|
||||
|
||||
# Probe duration
|
||||
dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$f")
|
||||
[ -z "$dur" ] && die "Could not read duration of: $f"
|
||||
dur_ms=$(awk "BEGIN { printf \"%d\", $dur * 1000 }")
|
||||
|
||||
# Chapter title from filename (strip path and extension)
|
||||
title=$(basename "$f")
|
||||
title="${title%.*}"
|
||||
|
||||
end_ms=$((cumulative_ms + dur_ms))
|
||||
cat >> "$metadata" <<EOF
|
||||
|
||||
[CHAPTER]
|
||||
TIMEBASE=1/1000
|
||||
START=$cumulative_ms
|
||||
END=$end_ms
|
||||
title=$title
|
||||
EOF
|
||||
cumulative_ms=$end_ms
|
||||
done
|
||||
|
||||
# --- Concatenate ---
|
||||
|
||||
ffmpeg -y -f concat -safe 0 -i "$concat_list" -i "$metadata" \
|
||||
-map_metadata 1 -c copy -movflags +faststart "$output" 2>&1
|
||||
|
||||
# --- Summary ---
|
||||
|
||||
total_secs=$((cumulative_ms / 1000))
|
||||
mins=$((total_secs / 60))
|
||||
secs=$((total_secs % 60))
|
||||
|
||||
echo ""
|
||||
echo "Done: $output"
|
||||
echo " Chapters: ${#inputs[@]}"
|
||||
echo " Duration: ${mins}m ${secs}s"
|
||||
Loading…
x
Reference in New Issue
Block a user