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.1901';
7              
8 17     17   19413 use strictures 2;
  17         26288  
  17         613  
9 17     17   11445 use List::MoreUtils qw/ any first_index /;
  17         219087  
  17         101  
10 17     17   25778 use Moo;
  17         186060  
  17         82  
11 17     17   28266 use Music::Note ();
  17         24459  
  17         448  
12 17     17   6637 use Music::Scales qw(get_scale_notes);
  17         77277  
  17         1268  
13 17     17   7168 use namespace::clean;
  17         180200  
  17         102  
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 281887 my ( $self, $chord ) = @_;
52              
53 1947 100       4099 die 'No chord to parse'
54             unless $chord;
55              
56 1946         5694 my $note_re = qr/[A-G][#b]?[#b]?/;
57              
58             # Get the roman representation of the scale
59 1946         3944 my @scale = $self->get_scale_mode;
60 1946 50       4242 print "SCALE: @scale\n" if $self->verbose;
61              
62 1946         2262 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     7174 if ( $self->scale_note =~ /##/ && $self->scale_name ne 'major' && $self->scale_name ne 'ionian' ) {
      66        
66 42         153 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         132 @notes = get_scale_notes( $self->major_tonic, 'major' );
77              
78             # Rotate the major scale to the correct mode
79 42         6271 push @notes, shift @notes for 1 .. $modes{ $self->scale_name } - 1;
80             }
81             else {
82 1904         5775 @notes = get_scale_notes( $self->scale_note, $self->scale_name );
83             }
84 1946 50       322265 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         3275 $chord =~ s/dim/o/;
102              
103             # Get just the note part of the chord name
104 1946         14541 ( my $note = $chord ) =~ s/^($note_re).*$/$1/;
105              
106 1946         8389 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       3759 $note = $bb_enharmonics{$note}
117             if $note =~ /bb$/;
118              
119             # Get the roman representation based on the scale position
120 1946     8155   8944 my $position = first_index { $_ eq $note } @notes;
  8155         10574  
121              
122 1946 100 100     9220 if ( $position < 0 && ( $note eq 'Cb' || $note eq 'Fb' ) ) {
    100 100        
123 56 100       152 $note = 'B'
124             if $note eq 'Cb';
125 56 100       109 $note = 'E'
126             if $note eq 'Fb';
127 56     329   145 $position = first_index { $_ eq $note } @notes;
  329         348  
128             }
129             elsif ( $note eq 'E#' ) { # XXX Why does this work?
130 62         113 $note = 'F';
131             }
132              
133 1946         2855 my $accidental = '';
134 1946 100 100     4434 if ( $position < 0 && $note =~ /[#b]+$/ ) {
135 208         660 my $n = Music::Note->new( $note, 'isobase' );
136 208         6224 my $name = $n->format('isobase');
137 208         4710 ( $accidental = $name ) =~ s/^[A-G]([#b]+)$/$1/;
138 208 100       810 $n->en_eq( $accidental =~ /^#/ ? 'b' : '#' );
139 208         2510 $note = $n->format('isobase');
140 208     1244   3739 $position = first_index { $_ eq $note } @notes;
  1244         2443  
141 208         842 $accidental = '';
142             }
143              
144             # If the note is not in the scale find the new position and accidental
145 1946 100       3111 if ( $position < 0 ) {
146 352         979 ( $position, $accidental ) = _pos_acc( $note, $position, \@notes );
147             }
148              
149 1946         2963 my $roman = $scale[$position];
150 1946 50       4090 print "ROMAN 1: $roman\n" if $self->verbose;
151              
152             # Get everything but the note part
153 1946         10721 ( my $decorator = $chord ) =~ s/^(?:$note_re)(.*)$/$1/;
154              
155             # Are we minor or diminished?
156 1946 100       6322 my $minor = $decorator =~ /[-moø]/ ? 1 : 0;
157 1946 50       3681 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       3526 if ( $self->chords ) {
161 719 100 100     1973 $roman = $minor && $decorator !~ /maj/i ? lc($roman) : uc($roman);
162             }
163              
164             # Add any accidental found in a non-scale note
165 1946 100       3516 $roman = $accidental . $roman if $accidental;
166 1946 50       3171 print "ROMAN 2: $roman\n" if $self->verbose;
167              
168             # Handle these unfortunate edge cases:
169 1946         3754 $roman = _up_to_flat( $roman, \@scale );
170 1946 50       3770 print "ROMAN 3: $roman\n" if $self->verbose;
171              
172             # Handle the decorator variations
173 1946 100 100     7094 if ( $decorator =~ /maj/i || $decorator =~ /min/i ) {
    100          
    100          
174 55         117 $decorator = lc $decorator;
175             }
176             elsif ( $decorator =~ /△/ ) {
177 1         4 $decorator =~ s/△/maj/;
178             }
179             elsif ( $decorator =~ /ø/ ) {
180 1         3 $decorator =~ s/ø/7b5/;
181             }
182             else {
183             # Drop the minor and major part of the chord name
184 1889         2901 $decorator =~ s/[-Mm]//i;
185             }
186 1946 50       3653 print "DECO: $decorator\n" if $self->verbose;
187              
188             # A remaining note name is a bass decorator
189 1946 100       5778 if ( $decorator =~ /($note_re)/ ) {
190 207         377 my $name = $1;
191              
192 207     1194   674 $position = first_index { $_ eq $name } @notes;
  1194         1297  
193 207 50       593 print "BASS NOTE: $name, POSN: $position\n" if $self->verbose;
194              
195 207 100       370 if ( $position >= 0 ) {
196 119         600 $decorator =~ s/$note_re/$scale[$position]/;
197             }
198             else {
199 88         191 ( $position, $accidental ) = _pos_acc( $name, $position, \@notes );
200 88 50       285 print "NEW POSN: $position, ACCI: $accidental\n" if $self->verbose;
201              
202 88         192 my $bass = $accidental . $scale[$position];
203 88         402 $decorator =~ s/$note_re/$bass/;
204              
205             # Handle these unfortunate edge cases
206 88         209 $decorator = _up_to_flat( $decorator, \@scale );
207             }
208 207 50       475 print "NEW DECO: $decorator\n" if $self->verbose;
209             }
210              
211             # Append the remaining decorator to the roman representation
212 1946         2990 $roman .= $decorator;
213              
214 1946         2671 $roman =~ s/bI\b/vii/g;
215 1946         2347 $roman =~ s/bIV\b/iii/g;
216              
217 1946 50       3488 print "ROMAN 4: $roman\n" if $self->verbose;
218              
219 1946         13605 return $roman;
220             }
221              
222              
223             sub get_scale_mode {
224 1946     1946 1 2747 my ($self) = @_;
225              
226 1946         4679 my @scale = qw( I ii iii IV V vi vii ); # Default to major/ionian
227              
228 1946 100 66     12480 if ( $self->scale_name eq 'dorian' ) {
    100          
    100          
    100          
    100          
    100          
229 126         429 @scale = qw( i ii III IV v vi VII );
230             }
231             elsif ( $self->scale_name eq 'phrygian' ) {
232 126         303 @scale = qw( i II III iv v VI vii );
233             }
234             elsif ( $self->scale_name eq 'lydian' ) {
235 126         300 @scale = qw( I II iii iv V vi vii );
236             }
237             elsif ( $self->scale_name eq 'mixolydian' ) {
238 126         377 @scale = qw( I ii iii IV v vi VII );
239             }
240             elsif ( $self->scale_name eq 'minor' || $self->scale_name eq 'aeolian' ) {
241 128         407 @scale = qw( i ii III iv v VI VII );
242             }
243             elsif ( $self->scale_name eq 'locrian' ) {
244 126         415 @scale = qw( i II iii iv V VI vii );
245             }
246              
247 1946         5532 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   3829 my ($numeral, $roman) = @_;
270              
271             # Change a roman sharp to a flat of the succeeding scale position
272 2034     664   4617 $numeral =~ s/#([IV]+)/b$roman->[ ( ( first_index { lc($1) eq lc($_) } @$roman ) + 1 ) % @$roman ]/i;
  664         1826  
273              
274 2034         4057 return $numeral;
275             };
276              
277             sub _pos_acc {
278 440     440   860 my ( $note, $position, $notes ) = @_;
279              
280 440         509 my $accidental;
281              
282             # If the note has no accidental...
283 440 100       769 if ( length($note) == 1 ) {
284             # Find the scale position of the closest similar note
285 265     1091   834 $position = first_index { $_ =~ /^$note/ } @$notes;
  1091         4448  
286              
287             # Get the accidental of the scale note
288 265         1294 ( $accidental = $notes->[$position] ) =~ s/^[A-G](.)$/$1/;
289              
290             # TODO: Explain why.
291 265 100       586 $accidental = $accidental eq '#' ? 'b' : '#';
292             }
293             else {
294             # Enharmonic double sharp equivalents
295 175         702 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   663 if exists $previous_enharmonics{$note} && any { $_ =~ /[CFG]##/ } @$notes;
  611         1307  
305              
306             # Get the accidental of the given note
307 175         779 ( my $letter, $accidental ) = $note =~ /^([A-G])(.+)$/;
308              
309             # Get the scale position of the closest similar note
310 175     767   589 $position = first_index { $_ =~ /^$letter/ } @$notes;
  767         2706  
311              
312 175 100       633 $accidental = $accidental eq '##' ? 'b' : $accidental;
313             }
314              
315 440         1030 return $position, $accidental;
316             }
317              
318             sub _valid_note {
319 300     300   611 my ($note) = @_;
320              
321 300         443 my @valid = ();
322              
323 300         766 my @notes = 'A' .. 'G';
324              
325 300         706 push @valid, @notes;
326 300         486 push @valid, map { $_ . '#' } @notes;
  2100         3571  
327 300         712 push @valid, map { $_ . '##' } @notes;
  2100         3329  
328 300         516 push @valid, map { $_ . 'b' } @notes;
  2100         3285  
329              
330 300     2161   1383 return any { $_ eq $note } @valid;
  2161         8342  
331             }
332              
333             sub _valid_scale {
334 150     150   314 my ($name) = @_;
335              
336 150         478 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   581 return any { $_ eq $name } @valid;
  681         3604  
349             }
350              
351             1;
352              
353             __END__