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.1900';
7              
8 17     17   21677 use List::MoreUtils qw/ any first_index /;
  17         213462  
  17         115  
9 17     17   27027 use Moo;
  17         179427  
  17         89  
10 17     17   30584 use Music::Note;
  17         25313  
  17         530  
11 17     17   7549 use Music::Scales;
  17         77800  
  17         1636  
12              
13 17     17   10922 use strictures 2;
  17         25966  
  17         705  
14 17     17   11214 use namespace::clean;
  17         185555  
  17         105  
15              
16              
17             has scale_note => (
18             is => 'ro',
19             isa => sub { die 'Invalid note' unless _valid_note( $_[0] ) },
20             default => sub { 'C' },
21             );
22              
23              
24             has scale_name => (
25             is => 'ro',
26             isa => sub { die 'Invalid scale' unless _valid_scale( $_[0] ) },
27             default => sub { 'major' },
28             );
29              
30              
31             has major_tonic => (
32             is => 'ro',
33             isa => sub { die 'Invalid note' unless _valid_note( $_[0] ) },
34             default => sub { 'C' },
35             );
36              
37              
38             has chords => (
39             is => 'ro',
40             isa => sub { die 'Invalid boolean' unless $_[0] == 0 || $_[0] == 1 },
41             default => sub { 1 },
42             );
43              
44              
45             has verbose => (
46             is => 'ro',
47             default => sub { 0 },
48             );
49              
50              
51             sub parse {
52 1947     1947 1 252028 my ( $self, $chord ) = @_;
53              
54 1947 100       4170 die 'No chord to parse'
55             unless $chord;
56              
57 1946         5883 my $note_re = qr/[A-G][#b]?[#b]?/;
58              
59             # Get the roman representation of the scale
60 1946         3725 my @scale = $self->get_scale_mode;
61 1946 50       4295 print "SCALE: @scale\n" if $self->verbose;
62              
63 1946         2386 my @notes;
64              
65             # If the note has a double sharp and is not in major, manually rotate the scale notes, since Music::Scales does not.
66 1946 100 66     6562 if ( $self->scale_note =~ /##/ && $self->scale_name ne 'major' && $self->scale_name ne 'ionian' ) {
      66        
67 42         155 my %modes = (
68             dorian => 2,
69             phrygian => 3,
70             lydian => 4,
71             mixolydian => 5,
72             aeolian => 6,
73             minor => 6,
74             locrian => 7,
75             );
76              
77 42         126 @notes = get_scale_notes( $self->major_tonic, 'major' );
78              
79             # Rotate the major scale to the correct mode
80 42         6489 push @notes, shift @notes for 1 .. $modes{ $self->scale_name } - 1;
81             }
82             else {
83 1904         5888 @notes = get_scale_notes( $self->scale_note, $self->scale_name );
84             }
85 1946 50       299096 print "NOTES: @notes\n" if $self->verbose;
86              
87             # XXX Not working?
88             # my %ss_enharmonics = (
89             # 'C##' => 'D',
90             # 'D##' => 'E',
91             # 'F##' => 'G',
92             # 'G##' => 'A',
93             # 'A##' => 'B',
94             # );
95             # for ( @notes ) {
96             # $_ = $ss_enharmonics{$_}
97             # if $ss_enharmonics{$_};
98             # }
99             #use Data::Dumper;warn(__PACKAGE__,' ',__LINE__," MARK: ",Dumper\@notes);
100              
101             # Convert a diminished chord
102 1946         3076 $chord =~ s/dim/o/;
103              
104             # Get just the note part of the chord name
105 1946         15068 ( my $note = $chord ) =~ s/^($note_re).*$/$1/;
106              
107 1946         8525 my %bb_enharmonics = (
108             Cbb => 'Bb',
109             Dbb => 'C',
110             Ebb => 'D',
111             Fbb => 'Eb',
112             Gbb => 'F',
113             Abb => 'G',
114             Bbb => 'A',
115             );
116              
117 1946 100       3993 $note = $bb_enharmonics{$note}
118             if $note =~ /bb$/;
119              
120             # Get the roman representation based on the scale position
121 1946     8155   9179 my $position = first_index { $_ eq $note } @notes;
  8155         10227  
122              
123 1946 100 100     9100 if ( $position < 0 && ( $note eq 'Cb' || $note eq 'Fb' ) ) {
    100 100        
124 56 100       126 $note = 'B'
125             if $note eq 'Cb';
126 56 100       113 $note = 'E'
127             if $note eq 'Fb';
128 56     329   156 $position = first_index { $_ eq $note } @notes;
  329         361  
129             }
130             elsif ( $note eq 'E#' ) { # XXX Why does this work?
131 62         132 $note = 'F';
132             }
133              
134 1946         2930 my $accidental = '';
135 1946 100 100     4589 if ( $position < 0 && $note =~ /[#b]+$/ ) {
136 208         839 my $n = Music::Note->new( $note, 'isobase' );
137 208         6639 my $name = $n->format('isobase');
138 208         4781 ( $accidental = $name ) =~ s/^[A-G]([#b]+)$/$1/;
139 208 100       896 $n->en_eq( $accidental =~ /^#/ ? 'b' : '#' );
140 208         2619 $note = $n->format('isobase');
141 208     1244   3724 $position = first_index { $_ eq $note } @notes;
  1244         1415  
142 208         815 $accidental = '';
143             }
144              
145             # If the note is not in the scale find the new position and accidental
146 1946 100       3099 if ( $position < 0 ) {
147 352         762 ( $position, $accidental ) = _pos_acc( $note, $position, \@notes );
148             }
149              
150 1946         2984 my $roman = $scale[$position];
151 1946 50       3820 print "ROMAN 1: $roman\n" if $self->verbose;
152              
153             # Get everything but the note part
154 1946         10868 ( my $decorator = $chord ) =~ s/^(?:$note_re)(.*)$/$1/;
155              
156             # Are we minor or diminished?
157 1946 100       4507 my $minor = $decorator =~ /[-moø]/ ? 1 : 0;
158 1946 50       3730 print "CHORD: $chord, NOTE: $note, NEW ACCI: $accidental, DECO: $decorator, MINOR: $minor, POSN: $position\n" if $self->verbose;
159              
160             # Convert the case of the roman representation based on minor or major
161 1946 100       3776 if ( $self->chords ) {
162 719 100 100     2072 $roman = $minor && $decorator !~ /maj/i ? lc($roman) : uc($roman);
163             }
164              
165             # Add any accidental found in a non-scale note
166 1946 100       3119 $roman = $accidental . $roman if $accidental;
167 1946 50       3280 print "ROMAN 2: $roman\n" if $self->verbose;
168              
169             # Handle these unfortunate edge cases:
170 1946         3804 $roman = _up_to_flat( $roman, \@scale );
171 1946 50       4168 print "ROMAN 3: $roman\n" if $self->verbose;
172              
173             # Handle the decorator variations
174 1946 100 100     7565 if ( $decorator =~ /maj/i || $decorator =~ /min/i ) {
    100          
    100          
175 55         125 $decorator = lc $decorator;
176             }
177             elsif ( $decorator =~ /△/ ) {
178 1         5 $decorator =~ s/△/maj/;
179             }
180             elsif ( $decorator =~ /ø/ ) {
181 1         5 $decorator =~ s/ø/7b5/;
182             }
183             else {
184             # Drop the minor and major part of the chord name
185 1889         2903 $decorator =~ s/[-Mm]//i;
186             }
187 1946 50       3407 print "DECO: $decorator\n" if $self->verbose;
188              
189             # A remaining note name is a bass decorator
190 1946 100       5802 if ( $decorator =~ /($note_re)/ ) {
191 207         394 my $name = $1;
192              
193 207     1194   708 $position = first_index { $_ eq $name } @notes;
  1194         1382  
194 207 50       605 print "BASS NOTE: $name, POSN: $position\n" if $self->verbose;
195              
196 207 100       393 if ( $position >= 0 ) {
197 119         628 $decorator =~ s/$note_re/$scale[$position]/;
198             }
199             else {
200 88         223 ( $position, $accidental ) = _pos_acc( $name, $position, \@notes );
201 88 50       245 print "NEW POSN: $position, ACCI: $accidental\n" if $self->verbose;
202              
203 88         209 my $bass = $accidental . $scale[$position];
204 88         437 $decorator =~ s/$note_re/$bass/;
205              
206             # Handle these unfortunate edge cases
207 88         231 $decorator = _up_to_flat( $decorator, \@scale );
208             }
209 207 50       525 print "NEW DECO: $decorator\n" if $self->verbose;
210             }
211              
212             # Append the remaining decorator to the roman representation
213 1946         2791 $roman .= $decorator;
214              
215 1946         2792 $roman =~ s/bI\b/vii/g;
216 1946         2468 $roman =~ s/bIV\b/iii/g;
217              
218 1946 50       3450 print "ROMAN 4: $roman\n" if $self->verbose;
219              
220 1946         14767 return $roman;
221             }
222              
223              
224             sub get_scale_mode {
225 1946     1946 1 2805 my ($self) = @_;
226              
227 1946         4499 my @scale = qw( I ii iii IV V vi vii ); # Default to major/ionian
228              
229 1946 100 66     12370 if ( $self->scale_name eq 'dorian' ) {
    100          
    100          
    100          
    100          
    100          
230 126         331 @scale = qw( i ii III IV v vi VII );
231             }
232             elsif ( $self->scale_name eq 'phrygian' ) {
233 126         318 @scale = qw( i II III iv v VI vii );
234             }
235             elsif ( $self->scale_name eq 'lydian' ) {
236 126         311 @scale = qw( I II iii iv V vi vii );
237             }
238             elsif ( $self->scale_name eq 'mixolydian' ) {
239 126         307 @scale = qw( I ii iii IV v vi VII );
240             }
241             elsif ( $self->scale_name eq 'minor' || $self->scale_name eq 'aeolian' ) {
242 128         340 @scale = qw( i ii III iv v VI VII );
243             }
244             elsif ( $self->scale_name eq 'locrian' ) {
245 126         345 @scale = qw( i II iii iv V VI vii );
246             }
247              
248 1946         4963 return @scale;
249             }
250              
251              
252             sub get_scale_chords {
253 0     0 1 0 my ($self) = @_;
254              
255 0         0 my %diminished = (
256             ionian => 'vii',
257             dorian => 'vi',
258             phrygian => 'v',
259             lydian => 'iv',
260             mixolydian => 'iii',
261             aeolian => 'ii',
262             locrian => 'i',
263             );
264 0 0       0 my @chords = map { m/^$diminished{ $self->scale_name }$/ ? 'dim' : m/^[A-Z]+$/ ? '' : 'm' } $self->get_scale_mode;
  0 0       0  
265              
266 0         0 return @chords;
267             }
268              
269             sub _up_to_flat {
270 2034     2034   3528 my ($numeral, $roman) = @_;
271              
272             # Change a roman sharp to a flat of the succeeding scale position
273 2034     664   3691 $numeral =~ s/#([IV]+)/b$roman->[ ( ( first_index { lc($1) eq lc($_) } @$roman ) + 1 ) % @$roman ]/i;
  664         1408  
274              
275 2034         4025 return $numeral;
276             };
277              
278             sub _pos_acc {
279 440     440   859 my ( $note, $position, $notes ) = @_;
280              
281 440         553 my $accidental;
282              
283             # If the note has no accidental...
284 440 100       854 if ( length($note) == 1 ) {
285             # Find the scale position of the closest similar note
286 265     1091   843 $position = first_index { $_ =~ /^$note/ } @$notes;
  1091         4715  
287              
288             # Get the accidental of the scale note
289 265         1269 ( $accidental = $notes->[$position] ) =~ s/^[A-G](.)$/$1/;
290              
291             # TODO: Explain why.
292 265 100       660 $accidental = $accidental eq '#' ? 'b' : '#';
293             }
294             else {
295             # Enharmonic double sharp equivalents
296 175         670 my %previous_enharmonics = (
297             'C#' => 'C##',
298             'Db' => 'C##',
299             'F#' => 'F##',
300             'Gb' => 'F##',
301             'G#' => 'G##',
302             'Ab' => 'G##',
303             );
304             $note = $previous_enharmonics{$note}
305 175 100 100 611   760 if exists $previous_enharmonics{$note} && any { $_ =~ /[CFG]##/ } @$notes;
  611         998  
306              
307             # Get the accidental of the given note
308 175         811 ( my $letter, $accidental ) = $note =~ /^([A-G])(.+)$/;
309              
310             # Get the scale position of the closest similar note
311 175     767   597 $position = first_index { $_ =~ /^$letter/ } @$notes;
  767         2998  
312              
313 175 100       704 $accidental = $accidental eq '##' ? 'b' : $accidental;
314             }
315              
316 440         1086 return $position, $accidental;
317             }
318              
319             sub _valid_note {
320 300     300   586 my ($note) = @_;
321              
322 300         535 my @valid = ();
323              
324 300         797 my @notes = 'A' .. 'G';
325              
326 300         795 push @valid, @notes;
327 300         530 push @valid, map { $_ . '#' } @notes;
  2100         3381  
328 300         716 push @valid, map { $_ . '##' } @notes;
  2100         3255  
329 300         542 push @valid, map { $_ . 'b' } @notes;
  2100         3076  
330              
331 300     2161   1532 return any { $_ eq $note } @valid;
  2161         8020  
332             }
333              
334             sub _valid_scale {
335 150     150   320 my ($name) = @_;
336              
337 150         483 my @valid = qw(
338             ionian
339             major
340             dorian
341             phrygian
342             lydian
343             mixolydian
344             aeolian
345             minor
346             locrian
347             );
348              
349 150     681   602 return any { $_ eq $name } @valid;
  681         3284  
350             }
351              
352             1;
353              
354             __END__