File Coverage

blib/lib/Music/ToRoman.pm
Criterion Covered Total %
statement 134 139 96.4
branch 69 84 82.1
condition 24 27 88.8
subroutine 22 23 95.6
pod 3 3 100.0
total 252 276 91.3


line stmt bran cond sub pod time code
1             package Music::ToRoman;
2             our $AUTHORITY = 'cpan:GENE';
3              
4             # ABSTRACT: Convert notes and chords to Roman numeral notation
5              
6             our $VERSION = '0.1902';
7              
8 17     17   20895 use strictures 2;
  17         28560  
  17         712  
9 17     17   12345 use List::SomeUtils qw(any first_index);
  17         221688  
  17         1552  
10 17     17   9564 use Moo;
  17         143248  
  17         86  
11 17     17   31521 use Music::Note ();
  17         29607  
  17         487  
12 17     17   8048 use Music::Scales qw(get_scale_notes);
  17         88598  
  17         1115  
13 17     17   7907 use namespace::clean;
  17         135368  
  17         112  
14              
15              
16             has scale_note => (
17             is => 'ro',
18             isa => sub { die 'Invalid note' unless _valid_note( $_[0] ) },
19             default => sub { 'C' },
20             );
21              
22              
23             has scale_name => (
24             is => 'ro',
25             isa => sub { die 'Invalid scale' unless _valid_scale( $_[0] ) },
26             default => sub { 'major' },
27             );
28              
29              
30             has major_tonic => (
31             is => 'ro',
32             isa => sub { die 'Invalid note' unless _valid_note( $_[0] ) },
33             default => sub { 'C' },
34             );
35              
36              
37             has chords => (
38             is => 'ro',
39             isa => sub { die 'Invalid boolean' unless $_[0] == 0 || $_[0] == 1 },
40             default => sub { 1 },
41             );
42              
43              
44             has verbose => (
45             is => 'ro',
46             default => sub { 0 },
47             );
48              
49              
50             sub parse {
51 1947     1947 1 321232 my ( $self, $chord ) = @_;
52              
53 1947 100       4717 die 'No chord to parse'
54             unless $chord;
55              
56 1946         6481 my $note_re = qr/[A-G][#b]?[#b]?/;
57              
58             # Get the roman representation of the scale
59 1946         4416 my @scale = $self->get_scale_mode;
60 1946 50       4844 print "SCALE: @scale\n" if $self->verbose;
61              
62 1946         2704 my @notes;
63              
64             # If the note has a double sharp and is not in major, manually rotate the scale notes, since Music::Scales does not.
65 1946 100 66     7023 if ( $self->scale_note =~ /##/ && $self->scale_name ne 'major' && $self->scale_name ne 'ionian' ) {
      66        
66 42         166 my %modes = (
67             dorian => 2,
68             phrygian => 3,
69             lydian => 4,
70             mixolydian => 5,
71             aeolian => 6,
72             minor => 6,
73             locrian => 7,
74             );
75              
76 42         139 @notes = get_scale_notes( $self->major_tonic, 'major' );
77              
78             # Rotate the major scale to the correct mode
79 42         7539 push @notes, shift @notes for 1 .. $modes{ $self->scale_name } - 1;
80             }
81             else {
82 1904         6338 @notes = get_scale_notes( $self->scale_note, $self->scale_name );
83             }
84 1946 50       345039 print "NOTES: @notes\n" if $self->verbose;
85              
86             # XXX Not working?
87             # my %ss_enharmonics = (
88             # 'C##' => 'D',
89             # 'D##' => 'E',
90             # 'F##' => 'G',
91             # 'G##' => 'A',
92             # 'A##' => 'B',
93             # );
94             # for ( @notes ) {
95             # $_ = $ss_enharmonics{$_}
96             # if $ss_enharmonics{$_};
97             # }
98             #use Data::Dumper;warn(__PACKAGE__,' ',__LINE__," MARK: ",Dumper\@notes);
99              
100             # Convert a diminished chord
101 1946         3404 $chord =~ s/dim/o/;
102              
103             # Get just the note part of the chord name
104 1946         17152 ( my $note = $chord ) =~ s/^($note_re).*$/$1/;
105              
106 1946         9683 my %bb_enharmonics = (
107             Cbb => 'Bb',
108             Dbb => 'C',
109             Ebb => 'D',
110             Fbb => 'Eb',
111             Gbb => 'F',
112             Abb => 'G',
113             Bbb => 'A',
114             );
115              
116 1946 100       4641 $note = $bb_enharmonics{$note}
117             if $note =~ /bb$/;
118              
119             # Get the roman representation based on the scale position
120 1946     8155   10548 my $position = first_index { $_ eq $note } @notes;
  8155         11826  
121              
122 1946 100 100     10057 if ( $position < 0 && ( $note eq 'Cb' || $note eq 'Fb' ) ) {
    100 100        
123 56 100       141 $note = 'B'
124             if $note eq 'Cb';
125 56 100       129 $note = 'E'
126             if $note eq 'Fb';
127 56     329   199 $position = first_index { $_ eq $note } @notes;
  329         429  
128             }
129             elsif ( $note eq 'E#' ) { # XXX Why does this work?
130 62         136 $note = 'F';
131             }
132              
133 1946         3407 my $accidental = '';
134 1946 100 100     5073 if ( $position < 0 && $note =~ /[#b]+$/ ) {
135 208         805 my $n = Music::Note->new( $note, 'isobase' );
136 208         7466 my $name = $n->format('isobase');
137 208         5267 ( $accidental = $name ) =~ s/^[A-G]([#b]+)$/$1/;
138 208 100       998 $n->en_eq( $accidental =~ /^#/ ? 'b' : '#' );
139 208         2881 $note = $n->format('isobase');
140 208     1244   4352 $position = first_index { $_ eq $note } @notes;
  1244         1716  
141 208         845 $accidental = '';
142             }
143              
144             # If the note is not in the scale find the new position and accidental
145 1946 100       3732 if ( $position < 0 ) {
146 352         869 ( $position, $accidental ) = _pos_acc( $note, $position, \@notes );
147             }
148              
149 1946         3575 my $roman = $scale[$position];
150 1946 50       4500 print "ROMAN 1: $roman\n" if $self->verbose;
151              
152             # Get everything but the note part
153 1946         12776 ( my $decorator = $chord ) =~ s/^(?:$note_re)(.*)$/$1/;
154              
155             # Are we minor or diminished?
156 1946 100       5579 my $minor = $decorator =~ /[-moø]/ ? 1 : 0;
157 1946 50       4362 print "CHORD: $chord, NOTE: $note, NEW ACCI: $accidental, DECO: $decorator, MINOR: $minor, POSN: $position\n" if $self->verbose;
158              
159             # Convert the case of the roman representation based on minor or major
160 1946 100       4561 if ( $self->chords ) {
161 719 100 100     2333 $roman = $minor && $decorator !~ /maj/i ? lc($roman) : uc($roman);
162             }
163              
164             # Add any accidental found in a non-scale note
165 1946 100       3690 $roman = $accidental . $roman if $accidental;
166 1946 50       3792 print "ROMAN 2: $roman\n" if $self->verbose;
167              
168             # Handle these unfortunate edge cases:
169 1946         4058 $roman = _up_to_flat( $roman, \@scale );
170 1946 50       4542 print "ROMAN 3: $roman\n" if $self->verbose;
171              
172             # Handle the decorator variations
173 1946 100 100     8097 if ( $decorator =~ /maj/i || $decorator =~ /min/i ) {
    100          
    100          
174 55         134 $decorator = lc $decorator;
175             }
176             elsif ( $decorator =~ /△/ ) {
177 1         5 $decorator =~ s/△/maj/;
178             }
179             elsif ( $decorator =~ /ø/ ) {
180 1         4 $decorator =~ s/ø/7b5/;
181             }
182             else {
183             # Drop the minor and major part of the chord name
184 1889         3395 $decorator =~ s/[-Mm]//i;
185             }
186 1946 50       4000 print "DECO: $decorator\n" if $self->verbose;
187              
188             # A remaining note name is a bass decorator
189 1946 100       6894 if ( $decorator =~ /($note_re)/ ) {
190 207         498 my $name = $1;
191              
192 207     1194   791 $position = first_index { $_ eq $name } @notes;
  1194         1592  
193 207 50       695 print "BASS NOTE: $name, POSN: $position\n" if $self->verbose;
194              
195 207 100       441 if ( $position >= 0 ) {
196 119         716 $decorator =~ s/$note_re/$scale[$position]/;
197             }
198             else {
199 88         207 ( $position, $accidental ) = _pos_acc( $name, $position, \@notes );
200 88 50       300 print "NEW POSN: $position, ACCI: $accidental\n" if $self->verbose;
201              
202 88         215 my $bass = $accidental . $scale[$position];
203 88         477 $decorator =~ s/$note_re/$bass/;
204              
205             # Handle these unfortunate edge cases
206 88         251 $decorator = _up_to_flat( $decorator, \@scale );
207             }
208 207 50       634 print "NEW DECO: $decorator\n" if $self->verbose;
209             }
210              
211             # Append the remaining decorator to the roman representation
212 1946         3199 $roman .= $decorator;
213              
214 1946         3141 $roman =~ s/bI\b/vii/g;
215 1946         2742 $roman =~ s/bIV\b/iii/g;
216              
217 1946 50       3839 print "ROMAN 4: $roman\n" if $self->verbose;
218              
219 1946         15792 return $roman;
220             }
221              
222              
223             sub get_scale_mode {
224 1946     1946 1 3250 my ($self) = @_;
225              
226 1946         5265 my @scale = qw( I ii iii IV V vi vii ); # Default to major/ionian
227              
228 1946 100 66     13654 if ( $self->scale_name eq 'dorian' ) {
    100          
    100          
    100          
    100          
    100          
229 126         391 @scale = qw( i ii III IV v vi VII );
230             }
231             elsif ( $self->scale_name eq 'phrygian' ) {
232 126         370 @scale = qw( i II III iv v VI vii );
233             }
234             elsif ( $self->scale_name eq 'lydian' ) {
235 126         361 @scale = qw( I II iii iv V vi vii );
236             }
237             elsif ( $self->scale_name eq 'mixolydian' ) {
238 126         364 @scale = qw( I ii iii IV v vi VII );
239             }
240             elsif ( $self->scale_name eq 'minor' || $self->scale_name eq 'aeolian' ) {
241 128         377 @scale = qw( i ii III iv v VI VII );
242             }
243             elsif ( $self->scale_name eq 'locrian' ) {
244 126         400 @scale = qw( i II iii iv V VI vii );
245             }
246              
247 1946         5697 return @scale;
248             }
249              
250              
251             sub get_scale_chords {
252 0     0 1 0 my ($self) = @_;
253              
254 0         0 my %diminished = (
255             ionian => 'vii',
256             dorian => 'vi',
257             phrygian => 'v',
258             lydian => 'iv',
259             mixolydian => 'iii',
260             aeolian => 'ii',
261             locrian => 'i',
262             );
263 0 0       0 my @chords = map { m/^$diminished{ $self->scale_name }$/ ? 'dim' : m/^[A-Z]+$/ ? '' : 'm' } $self->get_scale_mode;
  0 0       0  
264              
265 0         0 return @chords;
266             }
267              
268             sub _up_to_flat {
269 2034     2034   3990 my ($numeral, $roman) = @_;
270              
271             # Change a roman sharp to a flat of the succeeding scale position
272 2034     664   4209 $numeral =~ s/#([IV]+)/b$roman->[ ( ( first_index { lc($1) eq lc($_) } @$roman ) + 1 ) % @$roman ]/i;
  664         1567  
273              
274 2034         4574 return $numeral;
275             };
276              
277             sub _pos_acc {
278 440     440   1004 my ( $note, $position, $notes ) = @_;
279              
280 440         637 my $accidental;
281              
282             # If the note has no accidental...
283 440 100       904 if ( length($note) == 1 ) {
284             # Find the scale position of the closest similar note
285 265     1091   871 $position = first_index { $_ =~ /^$note/ } @$notes;
  1091         4845  
286              
287             # Get the accidental of the scale note
288 265         1445 ( $accidental = $notes->[$position] ) =~ s/^[A-G](.)$/$1/;
289              
290             # TODO: Explain why.
291 265 100       702 $accidental = $accidental eq '#' ? 'b' : '#';
292             }
293             else {
294             # Enharmonic double sharp equivalents
295 175         699 my %previous_enharmonics = (
296             'C#' => 'C##',
297             'Db' => 'C##',
298             'F#' => 'F##',
299             'Gb' => 'F##',
300             'G#' => 'G##',
301             'Ab' => 'G##',
302             );
303             $note = $previous_enharmonics{$note}
304 175 100 100 611   768 if exists $previous_enharmonics{$note} && any { $_ =~ /[CFG]##/ } @$notes;
  611         1228  
305              
306             # Get the accidental of the given note
307 175         929 ( my $letter, $accidental ) = $note =~ /^([A-G])(.+)$/;
308              
309             # Get the scale position of the closest similar note
310 175     767   665 $position = first_index { $_ =~ /^$letter/ } @$notes;
  767         3358  
311              
312 175 100       845 $accidental = $accidental eq '##' ? 'b' : $accidental;
313             }
314              
315 440         1254 return $position, $accidental;
316             }
317              
318             sub _valid_note {
319 300     300   638 my ($note) = @_;
320              
321 300         512 my @valid = ();
322              
323 300         867 my @notes = 'A' .. 'G';
324              
325 300         815 push @valid, @notes;
326 300         558 push @valid, map { $_ . '#' } @notes;
  2100         3858  
327 300         821 push @valid, map { $_ . '##' } @notes;
  2100         3706  
328 300         593 push @valid, map { $_ . 'b' } @notes;
  2100         3588  
329              
330 300     2161   1599 return any { $_ eq $note } @valid;
  2161         9387  
331             }
332              
333             sub _valid_scale {
334 150     150   307 my ($name) = @_;
335              
336 150         474 my @valid = qw(
337             ionian
338             major
339             dorian
340             phrygian
341             lydian
342             mixolydian
343             aeolian
344             minor
345             locrian
346             );
347              
348 150     681   631 return any { $_ eq $name } @valid;
  681         3780  
349             }
350              
351             1;
352              
353             __END__