#!/usr/local/bin/perl =head1 NAME B - encode WAV files to FLAC =head1 SYNOPSIS B [B<-d> I] B I defaults to the album B in the tags file; one of these is required. TODO: implement B<-d> =cut # POSIX.1-2017, Base Definitions, 3.282 Portable Filename Character Set says # [A-Za-z0-9._-] # https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html#tag_03_282 # Flac tags (set with `flac -T fieldname=...`) are Vorbis comments. # https://www.xiph.org/vorbis/doc/v-comment.html#fieldnames field names we care about: # - TITLE - Track/Work name # - VERSION - may be used to differentiate multiple versions of the same track title in a single collection. (e.g. remix info) # - ALBUM - The collection name to which this track belongs # - TRACKNUMBER - The track number of this piece # - ARTIST - The artist generally considered responsible for the track # - DATE - Date the track was recorded (XXX I use US release date) # I use one more, though I'm unsure where I got it. Did I make it up? # - DISCNUMBER - number in multi-disc collection # Input is a directory containing: # - trackNN.cdda.wav - WAV format files ripped from CD-DA audio tracks where NN is track number 01 - 99 # - tags - described below # - cover.front.jpeg - optional JPEG format file containing the album front cover # The tags file is composed of two blocks: # 1. album tags # 2. track tags # Album tags may be: # - ALBUM # - ARTIST # - DATE # - DISCNUMBER (optional) # Track tags may be any of the rest of the tags listed above, suffixed with # [N] where N is the track number. ARTIST, and only ARTIST, may also appear # in the track tags. In this case, it overrides the album artist. In order # to add artists, the album artist must be listed again. For example, Reba # McEntire's "The Heart Won't Lie" on the album "It's Your Call" features # Vince Gill, and is specified as: # ARTIST=Reba McEntire # ALBUM=It's Your Call # TITLE[5]=The Heart Won't Lie # ARTIST[5]=Reba McEntire # ARTIST[5]=Vince Gill package epg::flac::archive::encode; use v5.12; use warnings; sub mangle_for_file_name { my $fn = shift; $fn =~ s/\s+/_/g; $fn =~ s/[^A-Za-z0-9._]/-/g; $fn } sub read_tags { my $fn = shift; open(my $fh, '<', $fn) || die("open($fn): $!"); my %album; my @tracks; my $tracknum = 0; while (<$fh>) { chomp; s/^([^=[\]]+)(\[(\d+)])?=// || die("no field name in $_"); my $name = $1; # TODO validate $name # TODO album tags should be illegal after track tags if (defined($3)) { if ($3 == $tracknum + 2) { $tracknum++; } elsif ($3 != $tracknum + 1) { $tracknum++; # increment from 0 to 1 for error message die("illegal track number jump from $tracknum to $3") } push(@{$tracks[$tracknum]->{$name}}, $_); } else { push(@{$album{$name}}, $_); } } \%album, \@tracks } sub quote { my $s = shift; $s =~ s/'/'\\''/g; "'$s'" } sub main { my $input_directory = shift; my ($album, $tracks) = read_tags("$input_directory/tags"); if (!defined($album->{ALBUM}) || scalar(@{$album->{ALBUM}}) != 1) { die('exactly one ALBUM tag required') } my $album_tag = $album->{ALBUM}->[0]; # TODO -d support if (!defined($album->{ARTIST}) || scalar(@{$album->{ARTIST}}) != 1) { die('exactly one ARTIST tag required') } my $artist = $album->{ARTIST}->[0]; my $date; if (defined($album->{DATE})) { if (scalar(@{$album->{DATE}}) != 1) { die('one or zero DATE tags required') } $date = $album->{DATE}->[0]; } my @discnumber = (); if (defined($album->{DISCNUMBER})) { if (scalar(@{$album->{DISCNUMBER}}) != 1) { die('one or zero DISCNUMBER tags required') } @discnumber = (sprintf('%02d', $album->{DISCNUMBER}->[0])); } my $dir = join('/', '..', mangle_for_file_name($artist), mangle_for_file_name($album_tag)); say('set -ex'); say('mkdir -p ', quote($dir)); my $tracknum = 0; for my $track (@$tracks) { $tracknum++; if (!defined($track->{TITLE}) || scalar(@{$track->{TITLE}}) < 1) { die("at least one TITLE required for track $tracknum") } $track->{ALBUM} = $album->{ALBUM}; $track->{TRACKNUMBER} = [$tracknum]; if (!defined($track->{ARTIST}) && defined($album->{ARTIST})) { $track->{ARTIST} = $album->{ARTIST}; } if (defined($date)) { $track->{DATE} = [$date]; } if (@discnumber) { $track->{DISCNUMBER} = \@discnumber; } my $title = join(' ', @{$track->{TITLE}}); my $tracknum_s = sprintf('%02d', $tracknum); my $fn = join('/', $dir, join('_', @discnumber, $tracknum_s, mangle_for_file_name($title) . '.flac', ), ); -e $fn && die("cowardly refusing to clobber $fn"); my @pictures = ('--picture', quote('3|image/jpeg|||cover.front')); # TODO optional say(join(' ', 'flac -o', quote($fn), '--delete-input-file', '-V', '--no-padding', '--best', @pictures, (map { my $name = $_; map { ('-T', quote($name . '=' . $_)) } @{$track->{$_}} } sort(keys(%$track))), "track$tracknum_s.cdda.wav" )); } say('rm tags cover.front'); return 0; } if (!caller) { exit(main(@ARGV)) } 1;