From 6209a087d77235d57fda0bae03dd52c827dc7256 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Wed, 11 Feb 2026 09:45:27 +0100 Subject: [PATCH] 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 --- docs/sdlamp2-fsd.md | 7 +++ tools/concat_cassette.sh | 107 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100755 tools/concat_cassette.sh diff --git a/docs/sdlamp2-fsd.md b/docs/sdlamp2-fsd.md index a6b3d41..e8ade3b 100644 --- a/docs/sdlamp2-fsd.md +++ b/docs/sdlamp2-fsd.md @@ -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. diff --git a/tools/concat_cassette.sh b/tools/concat_cassette.sh new file mode 100755 index 0000000..e685f35 --- /dev/null +++ b/tools/concat_cassette.sh @@ -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" <&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"