package Tracks; use Moose; use Audio::FLAC::Header; use IO::Lines; use IO::File; use Digest::SHA1; # conversion factors use constant FRAMES_PER_SECOND => 75; use constant SECONDS_PER_MINUTE => 60; # see also http://flac.sourceforge.net/format.html#cuesheet_track # 588 samples/frame = 44100 samples/sec / 75 frames/sec use constant SAMPLES_PER_FRAME => 588; # XXX: does this every vary? # or is the lead-in always 88200 samples (88200 / 588 = 150) i.e. 2 seconds? use constant SECTOR_OFFSET => 150; has tracks => ( is => 'rw', isa => 'ArrayRef[HashRef]', default => sub { [] }, ); has discid => ( is => 'ro', isa => 'Str', builder => '_calculate_musicbrainz_discid', lazy => 1, init_arg => undef, ); sub _get_tracks_from_cdinfo { my $device = shift; my @tracks; open my $CD_INFO, 'cd-info -q |' or die "Unable to run cd-info: $!"; while (<$CD_INFO>) { next unless /^\s*([0-9]+): \d\d:\d\d:\d\d (\d{6})/; my ($num, $sector) = ($1, $2); my $track = { number => $num, sector => $sector, }; # place leadout track (170) at index 0 $num != 170 ? $tracks[$num] = $track : $tracks[0] = $track; } close $CD_INFO; return @tracks; } sub _get_tracks_from_cdparanoia { my $device = shift; my @tracks; open my $CDP, 'cdparanoia -d ' . $device . ' -Q 2>&1 |' or die "Unable to run cdparanoia: $!"; while (<$CDP>) { if (m{ ^\s+(\d+)\. # track number \s+(\d+) # length \s+\[(\d\d:\d\d\.\d\d)\] # length (MSF) \s+(\d+) # start \s+\[(\d\d:\d\d\.\d\d)\] # start (MSF) }x) { my ($track, $length, $length_msf, $start, $start_msf) = ($1, $2, $3, $4, $5); $start_msf =~ s/\./:/; $tracks[$track] = { number => $track, sector => $start, msf => $start_msf, }; } elsif (m{TOTAL\s+(\d+)}) { my $total = $1; my $leadout = $total + $tracks[1]{sector}; $tracks[0] = { number => 170, sector => $leadout, }; } } close $CDP; return @tracks; } sub read_disc { my ($self, $device) = @_; $self->tracks([ _get_tracks_from_cdparanoia($device) ]); } sub parse_cuesheet { my ($handle) = @_; my @tracks; my $track; while (<$handle>) { if (/TRACK (\d\d)/) { $track = int($1); } elsif (/INDEX 01/) { my ($m,$s,$f) = /INDEX 01 (\d\d):(\d\d):(\d\d)/; my $sector = ($m * SECONDS_PER_MINUTE * FRAMES_PER_SECOND) + ($s * FRAMES_PER_SECOND) + $f; $tracks[$track] = { number => $track, sector => $sector, msf => "$m:$s:$f", }; } elsif (/lead-out/) { my ($total_samples) = /lead-out \d+ (\d+)/; $tracks[0] = { number => 170, sector => $total_samples / SAMPLES_PER_FRAME, }; } } return @tracks; } sub read_flac { my ($self, $file) = @_; my $flac = ref $file ? $file : Audio::FLAC::Header->new($file); my $cuesheet_lines = $flac->cuesheet; my $CUE = IO::Lines->new($cuesheet_lines); $self->tracks([ parse_cuesheet($CUE) ]); } sub read_cue { my ($self, $file) = @_; my $CUE = $file eq '-' ? \*STDIN : IO::File->new($file, '<'); $self->tracks([ parse_cuesheet($CUE) ]); } sub has_tracks { my $self = shift; return @{ $self->tracks } > 0; } # https://musicbrainz.org/doc/Disc_ID_Calculation sub _calculate_musicbrainz_discid { my ($self) = @_; my @tracks = @{ $self->tracks }; return unless @tracks; my $sha1 = Digest::SHA1->new; $sha1->add(sprintf('%02X', $tracks[1]{number})); $sha1->add(sprintf('%02X', $tracks[-1]{number})); for my $i (0 .. 99) { my $offset = (defined $tracks[$i]{sector} ? ($tracks[$i]{sector} + SECTOR_OFFSET) : 0); $sha1->add(sprintf('%08X', $offset)); } my $digest = $sha1->b64digest; $digest =~ tr{+/=}{._-}; $digest .= '-'; ## why do we need to manually add this? return $digest; } sub get_musicbrainz_tocdata { my ($self) = @_; my @tracks = @{ $self->tracks }; # this is a CD TOC suitable for submitting to MusicBrainz as a CD Stub # http://musicbrainz.org/doc/XML_Web_Service#Submitting_a_CDStub return ( # first track number $tracks[1]{number}, # last track number $tracks[-1]{number}, # last frame (sector?) $tracks[0]{sector} + SECTOR_OFFSET, # start frame for each track map { $_->{sector} + SECTOR_OFFSET } @tracks[1 .. @tracks - 1], ); } sub get_cuesheet { my ($self) = @_; my @tracks = @{ $self->tracks }; my @cuesheet; push @cuesheet, qq{FILE "cdda.wav" WAVE}; for my $i (1 .. @tracks - 1) { my $track = $tracks[$i]; push @cuesheet, sprintf(' TRACK %02d AUDIO', $i); if ($i == 1 && $track->{sector} != 0) { push @cuesheet, ' INDEX 00 00:00:00'; } push @cuesheet, ' INDEX 01 ' . $track->{msf}; } return join('', map { "$_\n" } @cuesheet); } # module return 1;