File Coverage

blib/lib/Music/Lyrics/LRC.pm
Criterion Covered Total %
statement 81 81 100.0
branch 17 30 56.6
condition n/a
subroutine 16 16 100.0
pod 8 8 100.0
total 122 135 90.3


line stmt bran cond sub pod time code
1             package Music::Lyrics::LRC;
2              
3             # Force me to write this properly
4 3     3   240221 use strict;
  3         26  
  3         92  
5 3     3   16 use warnings;
  3         7  
  3         73  
6 3     3   15 use utf8;
  3         6  
  3         19  
7              
8             # Target reasonably old Perl
9 3     3   135 use 5.006;
  3         10  
10              
11             # Include required modules
12 3     3   19 use Carp;
  3         6  
  3         189  
13 3     3   666 use English '-no_match_vars';
  3         3864  
  3         20  
14              
15             # Declare package version
16             our $VERSION = '0.16';
17              
18             # Patterns to match elements of the LRC file; these are somewhat tolerant
19             our %RE = (
20              
21             # A blank line
22             blank => qr{
23             \A # Start of string
24             \s* # Any whitespace
25             \z # End of string
26             }msx,
27              
28             # A meta tag line
29             tag => qr{
30             \A # Start of string
31             \s* # Any whitespace
32             \[ # Opening left bracket
33             ([^:\r\n]+) # Tag name, capture
34             : # Colon
35             (.*) # Tag value, capture
36             \] # Closing right bracket
37             \s* # Any whitespace
38             \z # End of string
39             }msx,
40              
41             # A lyric line
42             lyric => qr{
43             \A # Start of string
44             \s* # Any whitespace
45             \[ # Opening left bracket
46             (\d+) # Minutes, capture
47             : # Colon
48             ( # Seconds group, capture
49             \d{1,2} # Whole seconds
50             (?: # Group for fractional seconds
51             [.] # Period
52             \d+ # At least one digit
53             )? # End optional fractional seconds group
54             ) # End seconds group
55             \] # Closing right bracket
56             [\t ]* # Any tabs or spaces
57             ( # Lyric line group, capture
58             (?:.*\S)? # Anything ending with non-whitespace
59             ) # End lyric line group
60             \s* # Any whitespace
61             \z # End of string
62             }msx,
63             );
64              
65             # Parser functions to consume and process captures from the above patterns
66             my %parsers = (
67              
68             # A meta tag line
69             tag => sub {
70             my ( $self, $tag, $value ) = @_;
71             $self->set_tag( $tag, $value );
72             },
73              
74             # A lyric line
75             lyric => sub {
76             my ( $self, $min, $sec, $text ) = @_;
77              
78             # Calculate the number of milliseconds
79             my $msec = $self->_min_sec_to_msec( $min, $sec );
80              
81             # Push a lyric hashref onto our list
82             $self->add_lyric( $msec, $text );
83             },
84             );
85              
86             # Oldschool constructor
87             sub new {
88 3     3 1 316 my ( $class, %opts ) = @_;
89              
90             # Declare a hash to build the object around
91 3         7 my %self;
92              
93             # Start with empty tags and lyrics
94 3         11 $self{tags} = {};
95 3         9 $self{lyrics} = [];
96              
97             # Read in the "verbose" flag if defined, default to zero
98             $self{verbose} =
99             exists $opts{verbose}
100             ? !!$opts{verbose}
101 3 50       15 : 0;
102              
103             # Perlician, bless thyself
104 3         15 return bless \%self, $class;
105             }
106              
107             # Read-only accessor for lyrics, sorted by time
108             sub lyrics {
109 4     4 1 287 my $self = shift;
110 4         9 my @lyrics = sort { $a->{time} <=> $b->{time} } @{ $self->{lyrics} };
  12         33  
  4         24  
111 4         22 return \@lyrics;
112             }
113              
114             # Read-only accessor for tags
115             sub tags {
116 1     1 1 3 my $self = shift;
117 1         2 my %tags = %{ $self->{tags} };
  1         6  
118 1         5 return \%tags;
119             }
120              
121             # Add a new lyric to the object
122             sub add_lyric {
123 8     8 1 1383 my ( $self, $time, $text ) = @_;
124              
125             # Check parameters
126 8 50       25 int $time >= 0
127             or croak 'Bad lyric time';
128 8 50       29 $text !~ m/ [\r\n] /msx
129             or croak 'Bad lyric line';
130              
131             # Push the lyric onto our list
132 8         15 return push @{ $self->{lyrics} },
  8         54  
133             {
134             time => $time,
135             text => $text,
136             };
137             }
138              
139             # Set the value of a tag
140             sub set_tag {
141 4     4 1 17 my ( $self, $name, $value ) = @_;
142              
143             # Check parameters
144 4 50       21 $name !~ m/ [:\r\n] /msx
145             or croak 'Bad tag name';
146              
147             # Tag content cannot have vertical whitespace
148 4 50       42 $value !~ m/ [\r\n] /msx
149             or croak 'Bad tag value';
150              
151             # Set the tag's value on our hash
152 4         27 return ( $self->{tags}{$name} = $value );
153             }
154              
155             # Unset a tag
156             sub unset_tag {
157 1     1 1 4 my ( $self, $name ) = @_;
158              
159             # Check parameters
160 1 50       6 $name !~ m/ [:\r\n] /msx
161             or croak 'Bad tag name';
162 1 50       4 exists $self->{tags}{$name}
163             or carp 'Tag not set';
164              
165             # Delete the tag's value
166 1         6 return defined delete $self->{tags}{$name};
167             }
168              
169             # Parse an LRC file from a given filehandle
170             sub load {
171 1     1 1 50 my ( $self, $fh ) = @_;
172              
173             # Panic if this doesn't look like a filehandle
174 1 50       6 ref $fh eq 'GLOB'
175             or croak 'Not a filehandle';
176              
177             # Iterate through lines
178 1         17 LINE: while ( my $line = <$fh> ) {
179              
180             # Iterate through line types until one matches
181 10         23 TYPE: for my $type (qw(lyric tag blank)) {
182 20 100       125 my @vals = $line =~ $RE{$type}
183             or next TYPE;
184 9 100       30 exists $parsers{$type}
185             or next LINE;
186 6         20 $parsers{$type}->( $self, @vals );
187 6         42 next LINE;
188             }
189              
190             # No line format match, warn if verbose
191 1 50       6 warn "Unknown format for line $NR\n" if $self->{verbose};
192             }
193              
194             # Check we got to the end of the file
195 1 50       21 eof $fh or die "Failed file read: $ERRNO\n";
196              
197             # All done, return the number of lyrics we have now
198 1         3 return scalar @{ $self->lyrics };
  1         5  
199             }
200              
201             # Write an LRC file to a given filehandle
202             sub save {
203 1     1 1 790 my ( $self, $fh ) = @_;
204              
205             # Panic if this doesn't look like a filehandle
206 1 50       7 ref $fh eq 'GLOB'
207             or croak 'Not a filehandle';
208              
209             # Start counting lines written
210 1         2 my $lines = 0;
211              
212             # Iterate through tags
213 1         3 for my $name ( sort keys %{ $self->{tags} } ) {
  1         6  
214 1         3 my $value = $self->{tags}{$name};
215 1 50       3 $lines += printf {$fh} "[%s:%s]\n", $name, $value
  1         25  
216             or die "Failed tag write: $ERRNO\n";
217             }
218              
219             # Iterate through lyrics (sorted by time)
220 1         4 for my $lyric ( @{ $self->lyrics } ) {
  1         5  
221              
222             # Convert milliseconds to timestamp hash
223 2         5 my $msec = $lyric->{time};
224 2         7 my ( $min, $sec ) = $self->_msec_to_min_sec($msec);
225              
226             # Write the line to the file, counting the lines
227 2         79 $lines += printf {$fh} "[%02u:%05.2f]%s\n", $min, $sec, $lyric->{text}
228 2 50       4 or die "Failed lyric write: $ERRNO\n";
229             }
230              
231             # Return the number of lines written
232 1         9 return $lines;
233             }
234              
235             # Named constants for the conversion functions
236             # This stands for "millisecond factors"
237             my %MSF = (
238             sec => 1_000,
239             min => 60_000,
240             );
241              
242             # Convert a minutes-seconds pair to milliseconds
243             sub _min_sec_to_msec {
244 4     4   10 my ( $self, $min, $sec ) = @_;
245 4         5 my $msec = 0;
246 4         11 $msec += int $min * $MSF{min};
247 4         11 $msec += $sec * $MSF{sec};
248 4         7 return $msec;
249             }
250              
251             # Convert milliseconds to a minutes-seconds pair
252             sub _msec_to_min_sec {
253 2     2   5 my ( $self, $msec ) = @_;
254 2         7 my $min = int $msec / $MSF{min};
255 2         5 my $sec = ( int $msec ) % $MSF{min} / $MSF{sec};
256 2         7 return ( $min, $sec );
257             }
258              
259             1;
260              
261             __END__