#!/usr/bin/env bash
# Generate synthetic albums of tiny tones for testing. No real music.
#
# Usage: scripts/make-fixtures.sh [STAGING_DIR] (default ~/music/staging)
set -euo pipefail
STAGING="${1:-$HOME/music/staging}"
POOL="$(mktemp -d)"
trap 'rm -rf "$POOL"' EXIT
command -v ffmpeg >/dev/null || { echo "need ffmpeg" >&2; exit 1; }
command -v metaflac >/dev/null || { echo "need metaflac" >&2; exit 1; }
echo "==> staging dir: $STAGING"
mkdir -p "$STAGING"
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
# base <name> <seconds> <freq> -- one lossless tone in the reusable pool
base() {
local name="$1" dur="$2" freq="$3"
ffmpeg -hide_banner -loglevel error -y \
-f lavfi -i "sine=frequency=${freq}:duration=${dur}:sample_rate=44100" \
-af "volume=0.2,afade=t=in:d=0.05,afade=t=out:st=$(awk "BEGIN{print $dur-0.1}"):d=0.1" \
-c:a flac -compression_level 5 "$POOL/$name.flac"
}
# make <album-dir> <spec...>
# spec forms:
# t:<title> title (required, may contain spaces/odd chars)
# any:key=value a tag; may be repeated
# Disc/track numbers fall back to position within the current disc.
make() {
local dir="$STAGING/$1"; shift
mkdir -p "$dir"
local disc=1 track=0 i=0 sz
local -a titles=() tags=()
# first pass: split specs
local spec
for spec in "$@"; do
case "$spec" in
t:*) titles+=("${spec#t:}") ;;
any:*) tags+=("${spec#any:}") ;;
*) echo "bad spec: $spec" >&2; return 1 ;;
esac
done
for spec in "${titles[@]}"; do
track=$((track+1))
i=$((i+1))
# cycle the tone pool so files are distinct but generation is cheap
case $((i % 3)) in
0) sz=pool_a ;; 1) sz=pool_b ;; *) sz=pool_c ;;
esac
cp "$POOL/$sz.flac" "$dir/tmp_$i.flac"
local -a alltags=()
# per-track tags supplied via any: apply to every track; callers that need
# per-track differences use the special any:ARTIST@N= form handled below.
local t
for t in "${tags[@]}"; do
case "$t" in
*"@"[0-9]*=*) ;; # per-track, handled after
*) alltags+=("$t") ;;
esac
done
alltags+=("TITLE=$spec")
# per-track overrides: KEY@N=value applies to track N
for t in "${tags[@]}"; do
case "$t" in
*"@"[0-9]*=*)
local key rest num val
key="${t%%@*}"
rest="${t#*@}"
num="${rest%%=*}"
val="${rest#*=}"
if [ "$num" = "$i" ]; then alltags+=("$key=$val"); fi
;;
esac
done
if [ ${#alltags[@]} -gt 0 ]; then
printf '%s\n' "${alltags[@]}" > "$dir/tmp_$i.tags"
metaflac --remove-all-tags --import-tags-from="$dir/tmp_$i.tags" "$dir/tmp_$i.flac"
rm -f "$dir/tmp_$i.tags"
else
metaflac --remove-all-tags "$dir/tmp_$i.flac"
fi
# name it something deliberately messy so renaming is exercised
mv "$dir/tmp_$i.flac" "$dir/$(printf '%02d' "$track") ${titles[$((i-1))]}.flac"
done
echo " made $(basename "$dir") (${#titles[@]} tracks)"
}
# art <album-dir> <width> <height> <colour> <label>
art() {
local dir="$STAGING/$1" w="$2" h="$3" colour="$4" label="$5"
ffmpeg -hide_banner -loglevel error -y -f lavfi \
-i "color=c=${colour}:s=${w}x${h}:d=1" -frames:v 1 \
-vf "drawtext=text='${label}':fontcolor=white:fontsize=$((w/12)):x=(w-text_w)/2:y=(h-text_h)/2" \
"$dir/source_art.png" 2>/dev/null \
|| ffmpeg -hide_banner -loglevel error -y -f lavfi \
-i "color=c=${colour}:s=${w}x${h}:d=1" -frames:v 1 "$dir/source_art.png"
}
# embed <album-dir> -- embed source_art.png as a front cover in every track
embed() {
local dir="$STAGING/$1" f
for f in "$dir"/*.flac; do
metaflac --import-picture-from="$dir/source_art.png" "$f"
done
rm -f "$dir/source_art.png"
}
# note <album-dir> <text> -- write FIXTURE.md
note() { printf '%s\n' "$2" > "$STAGING/$1/FIXTURE.md"; }
# rmclean <album-dir> <name...> -- remove paths (files or dirs), ignoring absence
rmclean() { local d="$1"; shift; local n; for n in "$@"; do rm -rf "$d/$n"; done; }
# ---------------------------------------------------------------------------
# the tone pool
# ---------------------------------------------------------------------------
echo "==> generating tone pool"
base pool_a 2 220
base pool_b 3 330
base pool_c 4 440
# ---------------------------------------------------------------------------
# fixtures
# ---------------------------------------------------------------------------
echo "==> building fixtures"
# --- 1. clean, well-tagged single-disc album -------------------------------
make "01-clean-single-disc" \
"t:First Light" "t:Second Wind" "t:Third Rail" \
any:ALBUM="Clean Single Disc" \
any:ALBUMARTIST="Test Artist" \
any:ARTIST="Test Artist" \
any:DATE="2015" \
any:TRACKNUMBER@1=1 any:TRACKNUMBER@2=2 any:TRACKNUMBER@3=3 \
any:DISCNUMBER=1
note "01-clean-single-disc" "# 01 clean single disc
Tests: the happy path. No extra tags, no crazy names, album art absent.
Expected:
- Common metadata displays ALBUM / ALBUMARTIST / DATE = 2015.
- No extra tags to strip (nothing to remove).
- Cover step must offer CAA/Deezer/iTunes plus placeholder (no embedded art).
- Names become 1-01_first-light.flac etc.
- Lands in archive/test-artist/clean-single-disc."
# --- 2. clean, well-tagged TWO-DISC album ---------------------------------
make "02-multi-disc" \
"t:Disc One Opener" "t:Disc One Closer" \
any:ALBUM="Multi Disc Set" any:ALBUMARTIST="Test Artist" any:ARTIST="Test Artist" \
any:DATE="2019" any:DISCNUMBER=1 \
any:TRACKNUMBER@1=1 any:TRACKNUMBER@2=2
make "02-multi-disc/Disc 2" \
"t:Disc Two Opener" "t:Disc Two Closer" \
any:ALBUM="Multi Disc Set" any:ALBUMARTIST="Test Artist" any:ARTIST="Test Artist" \
any:DATE="2019" any:DISCNUMBER=2 \
any:TRACKNUMBER@1=1 any:TRACKNUMBER@2=2
note "02-multi-disc" "# 02 multi-disc
Tests: DISC number handling and the always-on disc prefix.
Expected:
- Recognised as ONE album with 2 discs, not two albums.
- Names: 1-01_disc-one-opener.flac, 1-02_disc-one-closer.flac,
2-01_disc-two-opener.flac, 2-02_disc-two-closer.flac.
- All four land in the same archive/test-artist/multi-disc directory."
# --- 3. messy real-world tags --------------------------------------------
make "03-messy-tags" \
"t:Track With Genre" "t:Track With Comment" \
any:ALBUM="Messy Tags" any:ALBUMARTIST="Test Artist" any:ARTIST="Test Artist" \
any:DATE="2007-03-14" any:DISCNUMBER=1 \
any:TRACKNUMBER@1=1 any:TRACKNUMBER@2=2 \
any:GENRE="IDM" any:COMMENT="downloaded from somewhere" any:RATING="5" \
any:LABEL="Some Label" any:CATALOGNUMBER="CAT-001" \
any:REPLAYGAIN_TRACK_GAIN="-3.2 dB" any:ENCODER="old ripper 0.1" \
any:MOOD="chill" any:BPM="120"
note "03-messy-tags" "# 03 messy tags
Tests: tag stripping and DATE normalisation.
Expected:
- Review shows the extra tags being REMOVED: GENRE, COMMENT, RATING, LABEL,
CATALOGNUMBER, REPLAYGAIN_TRACK_GAIN, ENCODER, MOOD, BPM.
- DATE '2007-03-14' normalises to 2007.
- After writing, only the 7 whitelisted tags remain.
- Lands in archive/test-artist/messy-tags."
# --- 4. non-Latin titles, collisions, empty normalisation ----------------
make "04-unicode-and-symbols" \
"t:夜明け" "t:夜明け (Reprise)" "t:Café Münster" \
"t:Don't Stop" "t:Track (feat. Someone) [Remix]" "t:★★★★★" \
any:ALBUM="Unicode And Symbols" any:ALBUMARTIST="Test Artist" any:ARTIST="Test Artist" \
any:DATE="2021" any:DISCNUMBER=1
note "04-unicode-and-symbols" "# 04 unicode, symbols, collisions
Tests: THE RISKY ONE. NFKD transliteration, lossy normalisation, collisions,
and the name editor as the way out.
Expected with --yes and no editor action:
- '夜明け' and '★★★★★' both normalise to an empty name, so intake REFUSES to
guess rather than writing '1-06_.flac'. Nothing is written.
Expected interactively (or with a scripted editor):
- The editor opens on the names, with the empty ones shown blank and the
originals in the trailing comment.
- Filling in names lets the album through; the TITLE tags keep the original
Japanese and symbols, because a name the user typed is a filename, not a
licence to rewrite the tag.
- 'Café Münster' -> 'cafe-munster' (accent folding works), and
\"Don't Stop\" -> 'dont-stop' need no editing at all."
# --- 5. mixed art and mixed formats --------------------------------------
rmclean "$STAGING/05-art-and-formats" source_art.png cover.jpg README.txt notes.txt broken.flac broken.mp3
make "05-art-and-formats" \
"t:Has Embedded Art" "t:Also Has Art" \
any:ALBUM="Art And Formats" any:ALBUMARTIST="Test Artist" any:ARTIST="Test Artist" \
any:DATE="2012" any:TRACKNUMBER@1=1 any:TRACKNUMBER@2=2 any:DISCNUMBER=1
art "05-art-and-formats" 300 300 darkgreen "300x300"
embed "05-art-and-formats"
note "05-art-and-formats" "# 05 embedded art + a folder image + stray formats
Tests: cover candidates and non-audio clutter.
Expected:
- Cover step offers the EMBEDDED 300x300 image as a candidate.
- A separate cover.jpg is also present as a folder-image candidate.
- The 300x300 embedded art is LOW resolution: intake should warn that a better
source exists and prefer/preview the larger candidates.
- A 'broken.mp3' containing plain text and README.txt / notes.txt must be
ignored, not choke the scan."
# a larger folder image + non-audio clutter
art "05-art-and-formats" 1200 1200 navy "1200x1200"
mv "$STAGING/05-art-and-formats/source_art.png" "$STAGING/05-art-and-formats/cover.jpg"
printf 'clutter that must be ignored\n' > "$STAGING/05-art-and-formats/README.txt"
printf 'more clutter\n' > "$STAGING/05-art-and-formats/notes.txt"
printf 'not really an mp3\n' > "$STAGING/05-art-and-formats/broken.mp3"
# --- 6. compilation / various artists ------------------------------------
make "06-compilation" \
"t:Comp Track One" "t:Comp Track Two" "t:Comp Track Three" \
any:ALBUM="Big Compilation" any:ALBUMARTIST="Various Artists" \
any:DATE="2003" any:DISCNUMBER=1 \
any:TRACKNUMBER@1=1 any:TRACKNUMBER@2=2 any:TRACKNUMBER@3=3 \
any:ARTIST@1="Artist One" any:ARTIST@2="Artist Two" any:ARTIST@3="Artist Three"
note "06-compilation" "# 06 compilation / various artists
Tests: per-track ARTIST differing, ALBUMARTIST = Various Artists.
Expected:
- Review must show ARTIST varies per track (not hidden as 'common').
- Directory key is ALBUMARTIST, i.e. archive/various-artists/big-compilation
(NOT artist-one/big-compilation).
- Per-track ARTIST is preserved in the written tags."
# --- 7. no ARTIST tag at all (only ALBUMARTIST) --------------------------
make "07-missing-artist" \
"t:Only Album Artist" "t:Still Only Album Artist" \
any:ALBUM="Missing Artist" any:ALBUMARTIST="Fallback Artist" \
any:DATE="1999" any:DISCNUMBER=1 any:TRACKNUMBER@1=1 any:TRACKNUMBER@2=2
note "07-missing-artist" "# 07 missing ARTIST
Tests: ARTIST fallback.
Expected:
- ARTIST is absent on every track; intake fills it from ALBUMARTIST.
- Review should say so explicitly rather than silently inventing it.
- Lands in archive/fallback-artist/missing-artist."
# --- 8. tags stripped bare: filename is the only clue -------------------
make "08-no-tags-at-all" \
"t:Untagged One" "t:Untagged Two" "t:Untagged Three" \
any:DISCNUMBER=1
note "08-no-tags-at-all" "# 08 no usable tags
Tests: fully untagged audio (only DISCNUMBER present). The editor is the way to
supply everything.
Expected:
- ALBUM and ALBUMARTIST are missing, so intake does NOT invent them from the
directory name; it opens the editor with 'album:' and 'artist:' blank.
- Track numbers are missing: inferred from the leading filename digits, and the
inference is shown.
- Whatever the user types becomes both the tags and the folders.
- Nothing is written to archive/ until that happens."
# --- 9. 120 tracks: zero-padding past 99 --------------------------------
specs=()
for n in $(seq 1 120); do specs+=("t:Long Player Track $n"); done
specs+=("any:ALBUM=Hundred Twenty" "any:ALBUMARTIST=Test Artist" "any:ARTIST=Test Artist"
"any:DATE=2020" "any:DISCNUMBER=1")
make "09-past-ninety-nine" "${specs[@]}"
note "09-past-ninety-nine" "# 09 more than 99 tracks
Tests: zero-padding width decision.
Expected:
- With 120 tracks, 2-digit padding is impossible to keep unique AND consistent.
intake should widen the whole album to 3 digits (1-001_... through 1-120_...)
rather than mixing 1-99 and 1-100 widths.
- This fixture exists to make that decision concrete."
# --- 10. unusual album/artist names for directory normalisation ---------
make "10-naming-edge-cases" \
"t:Opener" "t:Closer" \
any:ALBUM="Álbum: The (Deluxe) Edition!" \
any:ALBUMARTIST="Björk & Sigur Rós" \
any:ARTIST="Björk & Sigur Rós" \
any:DATE="2001" any:DISCNUMBER=1 any:TRACKNUMBER@1=1 any:TRACKNUMBER@2=2
note "10-naming-edge-cases" "# 10 directory-name edge cases
Tests: normalisation of artist and album into directory names.
Expected:
- ALBUMARTIST 'Björk & Sigur Rós' -> 'bjork-and-sigur-ros' (or 'bjork-sigur-ros';
the exact treatment of '&' is what this fixture pins down).
- ALBUM 'Álbum: The (Deluxe) Edition!' -> 'album-the-deluxe-edition'.
- Confirm the review shows the proposed directories BEFORE creating them."
# --- 10b. artist credit with no ASCII form ------------------------------
make "10b-unromanisable-artist" \
"t:First Track" "t:Second Track" \
any:ALBUM="夜明け" any:ALBUMARTIST="椎名林檎" any:ARTIST="椎名林檎" \
any:DATE="2019" any:DISCNUMBER=1 any:TRACKNUMBER@1=1 any:TRACKNUMBER@2=2
note "10b-unromanisable-artist" "# 10b a credit and title with no ASCII form
Tests: the name editor and unromanisable artist resolution. Kanji carry no
readings in Unicode, so neither the artist folder nor the album folder can be
derived automatically.
Expected:
- The artist cannot be romanised, so intake asks for a folder name (or, under
--yes, uses a placeholder and defers to the editor).
- The editor opens with 'album:' and 'artist:' blank and the hints explaining
why, plus a track line whose name is empty after the generated prefix.
- Whatever the user types becomes the folder names; the ALBUM and ALBUMARTIST
TAGS keep the original Japanese text, so no metadata is lost.
- The decision is written to artists.toml, so the next album by this artist
resolves with no prompt."
# --- 11. empty staging entry (not an album) -----------------------------
mkdir -p "$STAGING/11-empty-dir"
note "11-empty-dir" "# 11 empty directory
Tests: an empty (or later, partially-downloaded) directory in staging.
Expected:
- scan reports it as not-an-album / incomplete and SKIPS it.
- intake album on it refuses with a clear message, exits non-zero."
# --- 12. single track ---------------------------------------------------
make "12-single-track" \
"t:The Only Song" \
any:ALBUM="Just One Song" any:ALBUMARTIST="Test Artist" any:ARTIST="Test Artist" \
any:DATE="2022" any:DISCNUMBER=1 any:TRACKNUMBER=1
note "12-single-track" "# 12 single-track release
Tests: degenerate album (one track). Common metadata must still display.
Expected:
- Works exactly like any other album with one track.
- Lands in archive/test-artist/just-one-song/1-01_the-only-song.flac."
# --- 13. long titles for filesystem limits ------------------------------
make "13-long-and-odd-names" \
"t:A Very Long Track Title That Goes On And On And Should Still Be Handled Gracefully Without Blowing Up The Filesystem Limit Because That Would Be Bad" \
"t:This Title Is Absurdly Long On Purpose To Force The Normaliser To Actually Truncate The Resulting Filename Rather Than Merely Come Close To The Limit Which Would Not Test Anything Useful At All" \
"t:trailing dots..." \
"t: leading and trailing spaces " \
"t:MIXED Case TiTlE" \
any:ALBUM="Long And Odd Names" any:ALBUMARTIST="Test Artist" any:ARTIST="Test Artist" \
any:DATE="2018" any:DISCNUMBER=1
note "13-long-and-odd-names" "# 13 long / odd / whitespace titles
Tests: filename length limits and whitespace hygiene.
Expected:
- The second track's normalised name would exceed 200 bytes, so intake must
TRUNCATE it, preserve the .flac extension, and show the shortening as a
warning before writing.
- The first track is long but under the cap, so it must NOT be truncated.
- Trailing dots and leading/trailing spaces are stripped (they break Windows and
are invisible).
- 'MIXED Case TiTlE' -> 'mixed-case-title'."
make "14-compilation-untagged" \
"t:Split One" "t:Split Two" "t:Split Three" "t:Split Four" \
any:ALBUM="Untagged Compilation" any:DATE="2001" any:DISCNUMBER=1 \
any:TRACKNUMBER@1=1 any:TRACKNUMBER@2=2 any:TRACKNUMBER@3=3 any:TRACKNUMBER@4=4 \
any:ARTIST@1="One Artist" any:ARTIST@2="Two Artist" \
any:ARTIST@3="Three Artist" any:ARTIST@4="Four Artist"
note "14-compilation-untagged" "# 14 compilation with no ALBUMARTIST
Tests: a genuine compilation that never had an ALBUMARTIST tag.
Expected:
- Files under archive/various-artists/untagged-compilation. No majority artist
among four different credits, so this is the various-artists case.
- Must NOT be refused. The album artist is only unknown when there is also no
reason to think it is a compilation; four different credits is that reason."
make "15-guest-tracks" \
"t:Solo One" "t:Solo Two" "t:Solo Three" "t:With A Guest" \
any:ALBUM="Guests" any:DATE="2012" any:DISCNUMBER=1 \
any:TRACKNUMBER@1=1 any:TRACKNUMBER@2=2 any:TRACKNUMBER@3=3 any:TRACKNUMBER@4=4 \
"any:ALBUM ARTIST=Main Artist" \
any:ARTIST@1="Main Artist" any:ARTIST@2="Main Artist" \
any:ARTIST@3="Main Artist" any:ARTIST@4="Main Artist + Guest"
note "15-guest-tracks" "# 15 album with guest tracks
Tests: an album whose tracks credit a guest, tagged ALBUM ARTIST with a space.
Expected:
- 'ALBUM ARTIST' means the same as ALBUMARTIST, so it is used and must not
appear in the list of tags being removed.
- Files under archive/main-artist/guests, NOT various-artists. One track
crediting a guest does not make an album a compilation.
- Per-track ARTIST keeps the guest credit on the written tags."
echo
echo "==> done. Fixtures in $STAGING"
echo "==> albums:"
for d in "$STAGING"/*/; do
n=$(find "$d" -name '*.flac' | wc -l)
printf ' %-28s %3s tracks\n' "$(basename "$d")" "$n"
done
echo
echo "NOTE: 11-empty-dir is intentionally empty."