#!/usr/bin/perl
use strict;

use vars qw($VERSION);
$VERSION = 1.016_001;

=pod

=head1 NAME

mp3cd - Perl script to burn audio CDs from lists of MP3s/WAVs/OGGs

=head1 OPTIONS

 -h, --help     Shows help
 -t DIR         Change temp directory to another location.
 -s STAGE       Start at a certain stage of processing:
                        clean   Start fresh (default, requires m3u file)
                        build   Does not clean (requires m3u file)
                        decode  Turns mp3s/oggs into WAVs
                        correct Fix up any WAV formats
                        norm    Normalizes WAV volumes
                        toc     Builds a Table of Contents from WAVs
                        toc_ok  Checks TOC validity
                        cdr_ok  Checks for a CDR
                        burn    Burns from the TOC
 -q             Quits after one stage of processing
 -V, --version  Report which version of the script this is

=head1 SYNOPSIS

This script implements the suggested methods outlined in the
Linux MP3 CD Burning mini-HOWTO
 http://tldp.org/HOWTO/mini/MP3-CD-Burning/

=head1 DESCRIPTION

This will burn a list (.m3u) of MP3s, OGGs, and/or WAVs to an audio CD.  It
handles making the WAVs sane by resampling if needed, and normalizing
the volume across all tracks.

If a failure happens, earlier stages can be skipped with the '-s' flag.
Some thing are time-consuming (like writing the WAVs from MP3s) and if
the CD burn failed, it's much nicer not to have to start over from 
scratch.  When doing this, you may not need the m3u file any more, since
the files have already been built.  See the list of stages using '-h'.

=head1 PREREQUISITES

Requires C<cdrdao>, and that /dev/cdrecorder is a valid symlink to the
/dev/sg device that cdrdao will use.  Use .cdrdao to edit driver
options.  (See "man cdrdao" for details.)

Requires C<mpg123> to decode mp3 to WAV files.
 http://www.mpg123.de/

Optionally requires C<oggdec> to decode ogg to WAV files.
 http://www.gnu.org/directory/audio/ogg/OggEnc.html/

Requires C<sox> to check/correct WAV formats.
 http://www.spies.com/Sox/

Requires C<normalize> to process the audio.
 http://www.cs.columbia.edu/~cvaill/normalize/

=head1 AUTHOR

 Kees Cook <kees@outflux.net>

 Contributors:

 J. Katz (ogg support)
 

=head1 SEE ALSO

perl(1), cdrdao(1), mpg123(1), oggdec(1), sox(1), normalize(1).

=head1 COPYRIGHT

 $Id: mp3cd,v 1.26 2003/11/21 06:15:50 nemesis Exp $
 Copyright (C) 2000-2003 Kees Cook
 kees@outflux.net, http://outflux.net/

 This program is free software; you can redistribute it and/or
 modify it under the terms of the GNU General Public License
 as published by the Free Software Foundation; either version 2
 of the License, or (at your option) any later version.

 This program is distributed in the hope that it will be useful,
 but WITHOUT ANY WARRANTY; without even the implied warranty of
 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 GNU General Public License for more details.

 You should have received a copy of the GNU General Public License
 along with this program; if not, write to the Free Software
 Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
 http://www.gnu.org/copyleft/gpl.html

=cut

# Change this to a location where you'll have at least a CD's worth of
# disk space available.  (For the WAVs)
# Its contents will be deleted, so be careful.  :)
my $BURNDIR="/scratch/mp3cd/".getpwuid($<);

# Filename to redirect sub-tool stdout/stderr
my $OUTPUT="tool-output.txt";

# Filename to write the TOC to
my $CDTOC="cdda.toc";

my $start=0;
my $START_CLEAN    = 0;
my $START_BUILD    = $START_CLEAN+1;
my $START_DECODE   = $START_BUILD+1;
my $START_CORRECT  = $START_DECODE+1;
my $START_NORM     = $START_CORRECT+1;
my $START_TOC      = $START_NORM+1;
my $START_TOC_OKAY = $START_TOC+1;
my $START_CDR_OKAY = $START_TOC_OKAY+1;
my $START_BURN     = $START_CDR_OKAY+1;

#JK - moved the argument evaluation code (below) forward in order to:
#     a) give the user a chance to set a new temp directory via the -t
#        flag without needing write permisson to the default $BURNDIR;
#     b) give program a chance to print out the usage message even if 
#        user does not have write permission to the default $BURNDIR;
#     c) gives us the m3u file earlier so we can check if it has any
#        oggs and would therefore require oggdec; if not, we won't
#        set oggdec as a prerequisite in order not to unnecessarily break
#        existing installations only using mp3s that don't have oggdec
my $quit=undef;
Usage() unless (@ARGV);
while ($ARGV[0] =~ /^-/) {
        my $arg=shift(@ARGV);
        Usage()   if ($arg eq "-h" || $arg eq "--help");
	Version() if ($arg eq "-V" || $arg eq "--version");
        $quit=1   if ($arg eq "-q");
	if ($arg eq "-t") {
		$BURNDIR=shift(@ARGV);
		die "Need a temp directory!\n" if ($BURNDIR eq "");
	}
        if ($arg eq "-s") {
                my $stage=shift(@ARGV);
                if      ($stage eq "clean")     { $start=$START_CLEAN; }
                elsif   ($stage eq "build")     { $start=$START_BUILD; }
                elsif   ($stage eq "decode")    { $start=$START_DECODE; }
                elsif   ($stage eq "correct")   { $start=$START_CORRECT; }
                elsif   ($stage eq "norm")      { $start=$START_NORM; }
		elsif   ($stage eq "toc")       { $start=$START_TOC; }
                elsif   ($stage eq "toc_ok")    { $start=$START_TOC_OKAY; }
                elsif   ($stage eq "cdr_ok")    { $start=$START_CDR_OKAY; }
                elsif   ($stage eq "burn")      { $start=$START_BURN; }
                else                            { Usage(); }
        }
}


# check for directory
if (!opendir(DIR, $BURNDIR)) {
	eval { mkdir($BURNDIR) };
	if ($@) {
		die "Can't create working directory '$BURNDIR': $@\n";
	}
	opendir(DIR, $BURNDIR) || die "Can't open directory '$BURNDIR': $!\n";
}
closedir DIR;


# check for required tools
my %found;
my @PREREQS=('sox','mpg123','normalize','cdrdao');
# only require oggdec if the m3u has oggs (if we're in a later stage, assume
# that this test has been evaluated by prior executions of the script).
push (@PREREQS, 'oggdec') if (defined($ARGV[0]) && need_oggdec());
foreach my $dir (split(/:/,$ENV{'PATH'})) {
	foreach my $prog (@PREREQS) {
		if (!defined($found{$prog}) && -x "$dir/$prog") {
			$found{$prog}="$dir/$prog";
			last;
		}
	}
}
my $abort=undef;
foreach my $prog (@PREREQS) {
	if (!defined($found{$prog})) {
		warn "Cannot find '$prog'!\n";
		$abort=1;
	}
}
Usage() if ($abort);

# check for CDR device
if (! -w "/dev/cdrecorder") {
	warn "Cannot write to /dev/cdrecorder!  cdrdao will be upset.\n";
	Usage();
}


Do_Clean()      if ($start <= $START_CLEAN);
Do_Build()      if ($start <= $START_BUILD);
Do_Decode()     if ($start <= $START_DECODE);
Do_Correct()    if ($start <= $START_CORRECT);
Do_Normalize()  if ($start <= $START_NORM);
Do_TOC()        if ($start <= $START_TOC);
Do_TOC_Verify() if ($start <= $START_TOC_OKAY);
Do_CDR_Check()  if ($start <= $START_CDR_OKAY);
Do_Burn()       if ($start <= $START_BURN);

sub Do_Clean
{
        print "Cleaing up...\n";

        # clear out burn dir
        system("rm -f $BURNDIR/*.mp3 $BURNDIR/*.wav $BURNDIR/*.ogg $BURNDIR/$OUTPUT $BURNDIR/$CDTOC");

        die "Stopping at user request...\n" if (defined($quit));
}

sub Do_Build
{
        # make sure they gave us a filename
        Usage() unless (defined($ARGV[0]));

        # read list of files
        open(M3U,$ARGV[0]) || die "Cannot open '$ARGV[0]': $!\n";
        my @files=<M3U>;
        close(M3U);

        # go there
        chdir($BURNDIR) || die "Cannot chdir('$BURNDIR'): $!\n";

	my $error=undef;
        my $count=0;
        # make link for each file, and retain extension
        foreach my $file (@files)
        {
                chomp($file);
                next if ($file =~ /^#/);
                my @parts=split(/\./,$file);
                my $ext=pop(@parts);
                $ext=~tr/A-Z/a-z/;

                my @parts=split(/\//,$file);
                my $name=pop(@parts);

                if ($ext ne "mp3" && $ext ne "wav" && $ext ne "ogg")
                {
                        warn "Error: '$file': unknown extension '$ext'!\n";
                        $error=1;
                        next;
                }

                if (!-f $file)
                {
                        warn "Error: '$file': $!\n";
                        $error=1;
                        next;
                }

                $count++;
                my $track=sprintf("%02d",$count);
                print "$track: [...]/$name\n";
                symlink($file,"$track.$ext") || die "symlink('$file','$count.$ext'): $!\n";
        }

        if (defined($error))
        {
                die ("Stopping due to errors...\n");
        }

        # make sure we have some tracks
        die("No tracks?!\n") unless ($count>0);

        die "Stopping at user request...\n" if (defined($quit));
}

sub Do_Decode
{
        # go there
        chdir($BURNDIR) || die "Cannot chdir('$BURNDIR'): $!\n";

        # get list of mp3s & oggs from directory (leaves any WAVs in playlist alone)
        opendir(DIR, $BURNDIR) || die "Can't read directory '$BURNDIR': $!\n";
        my @need_decode = grep { /^\d+\.(mp3|ogg)$/i && -f "$BURNDIR/$_" } readdir(DIR);
        closedir DIR;

        # turn mp3s & oggs into wav files
        foreach my $to_decode (sort {$a <=> $b} @need_decode)
        {
                my @parts=split(/\./,$to_decode);
                my $name=shift(@parts);
                my $ext=pop(@parts);
                my $file="${name}.wav";

                if (-f $file)
                {
                        print "Skipping track $name: $file exists.\n";
                }
                else
                {
                        print "Creating WAV for track $name ...\n";
                        if ($ext eq "mp3") {
			   #system("lame -S --decode $to_decode $file >$OUTPUT 2>&1") == 0
                           system("mpg123 --wav $file $to_decode >$OUTPUT 2>&1") == 0
                                   or die("Decoding failed!\n");
                        } elsif ($ext eq "ogg") {
                           system("oggdec $to_decode >$OUTPUT 2>&1") == 0
                                   or die("Decoding ogg failed!\n");
                        } else {
                           die("Invalid extension - decoding failed!\n");
                        }
                }
        }

        die "Stopping at user request...\n" if (defined($quit));
}

sub Do_Correct
{
        # go there
        chdir($BURNDIR) || die "Cannot chdir('$BURNDIR'): $!\n";

        # get list of wavs from directory
        opendir(DIR, $BURNDIR) || die "Can't read directory '$BURNDIR': $!\n";
        my @wavs = grep { /^\d+\.wav$/i && -f "$BURNDIR/$_" } readdir(DIR);
        closedir DIR;

        # correct any wav file formats
        foreach my $wav (sort {$a <=> $b} @wavs)
        {
                my @parts=split(/\./,$wav);
                my $name=shift(@parts);
		print "Checking WAV format for track $name ...\n";
                my $report=`sox -V $wav $wav.raw trim 0.1 1 2>&1`;
# sox: Reading Wave file: Microsoft PCM format, 2 channels, 44100 samp/sec
# sox:         176400 byte/sec, 4 block align, 16 bits/samp, 44886528 data bytes
                unless ($report =~ m|2 channels|s &&
                        $report =~ m|44100 samp/sec|s &&
		        $report =~ m|16 bits/samp|s)
                {

                        # only do a "resample" if rates aren't correct
                        my $resample="resample";
                        $resample="" if ($report =~ m|44100 samp/sec|s);
                        print "Correcting WAV format for track $name ...\n";
                        system("sox $wav -r 44100 -c 2 -w new-$wav $resample >$OUTPUT 2>&1") == 0
                                or die("Correction failed!\n");
                        unlink($wav) || die "unlink('$wav'): $!\n";
                        rename("new-$wav",$wav) || die "rename('new-$wav','$wav'): $!\n";
                }
		unlink("$wav.raw");
        }

        die "Stopping at user request...\n" if (defined($quit));
}

sub Do_Normalize
{
        # go there
        chdir($BURNDIR) || die "Cannot chdir('$BURNDIR'): $!\n";

        # normalize the volumes
        print "Normalizing volume levels...\n";
        system("normalize -m [0-9]*.wav") == 0
                or die("Normalizing failed!\n");
        print "Normalizing finished.\n";

        die "Stopping at user request...\n" if (defined($quit));
}

sub Do_TOC
{
        # go there
        chdir($BURNDIR) || die "Cannot chdir('$BURNDIR'): $!\n";

        # create a TOC for cdrdao
        open(TOC,">$CDTOC") || die("Cannot write to '$CDTOC': $!\n");
        print TOC "CD_DA\n\n";

        # get list of wavs
        opendir(DIR, $BURNDIR) || die "Can't read directory '$BURNDIR': $!\n";
        my @wavs = grep { /^\d+\.wav$/i && -f "$BURNDIR/$_" } readdir(DIR);
        closedir DIR;

        foreach my $wav (sort {$a <=> $b} @wavs)
        {
                die ("Yikes!  What happened to '$wav'?!\n") unless (-f $wav);
                print TOC "TRACK AUDIO\n";
                print TOC "FILE \"$wav\" 0 \n\n";
        }
	close TOC;

        die "Stopping at user request...\n" if (defined($quit));
}

sub Do_TOC_Verify
{
        # go there
        chdir($BURNDIR) || die "Cannot chdir('$BURNDIR'): $!\n";

        print "Verifying generated Table of Contents...\n";
        system("cdrdao read-test $CDTOC >$OUTPUT 2>&1") == 0
                or die "Failed to create CD Table of Contents?!\n";

        die "Stopping at user request...\n" if (defined($quit));
}

sub Do_CDR_Check
{
        # go there
        chdir($BURNDIR) || die "Cannot chdir('$BURNDIR'): $!\n";

        print "Checking for CDR...\n";
        system("cdrdao disk-info >$OUTPUT 2>&1") == 0
                or die "CDR not loaded?!\n";
        print "\tCDR found.\n";

        die "Stopping at user request...\n" if (defined($quit));
}

sub Do_Burn
{
        # go there
        chdir($BURNDIR) || die "Cannot chdir('$BURNDIR'): $!\n";

        #system("cdrdao simulate --eject $CDTOC") == 0
        system("cdrdao write -n --eject $CDTOC") == 0
                or die "BURN FAILED!\n";

        die "Stopping at user request...\n" if (defined($quit));
}

sub Usage
{
        die "Usage: $0 [-s stage] [-t directory] [-h] [m3u file]
-h, --help     Show this help
-t DIR         Change temp directory to another location.
-s STAGE       Start at a certain stage of processing:
                        clean   Start fresh (default, requires m3u file)
                        build   Does not clean (requires m3u file)
                        decode  Turns mp3s/oggs into WAVs
                        correct Fix up any WAV formats
                        norm    Normalizes WAV volumes
                        toc     Builds a Table of Contents from WAVs
                        toc_ok  Checks TOC validity
                        cdr_ok  Checks for a CDR
                        burn    Burns from the TOC
-q             Quits after one stage of processing
-V, --version  Report what version of the script this is\n";
}

sub Version
{
	# Create human-readable version with un-human-readable code
	warn "mp3cd version ".
		join(".",map{$_+0}$VERSION=~/^(\d+)\.(\d{3})(\d{3})$/)."\n";
        die <<EOM;
Copyright 2003 Kees Cook <kees\@outflux.net>
This program is free software; you may redistribute it under the terms of
the GNU General Public License.  This program has absolutely no warranty.
EOM
}

#check the playlist to see if we will need oggdec; if so return true
sub need_oggdec
{
	open(M3U,$ARGV[0]) || die "Cannot open '$ARGV[0]': $!\n";
	my @files=<M3U>;
	close(M3U);

        foreach my $file (@files) {
                chomp($file);
                next if ($file =~ /^#/);
                my @parts=split(/\./,$file);
                my $ext=pop(@parts);
                $ext=~tr/A-Z/a-z/;

                if ($ext eq "ogg") {
                        return 1;
                }
        }
        return 0;
}
