File Coverage

blib/lib/Music/Harmonica/TabsCreator/NoteToToneConverter.pm
Criterion Covered Total %
statement 75 75 100.0
branch 29 30 96.6
condition 4 7 57.1
subroutine 13 13 100.0
pod 0 5 0.0
total 121 130 93.0


line stmt bran cond sub pod time code
1             package Music::Harmonica::TabsCreator::NoteToToneConverter;
2              
3 4     4   177246 use 5.036;
  4         14  
4 4     4   18 use strict;
  4         6  
  4         70  
5 4     4   13 use warnings;
  4         16  
  4         160  
6 4     4   18 use utf8;
  4         5  
  4         42  
7              
8 4     4   134 use List::Util qw(any);
  4         25  
  4         265  
9 4     4   2133 use Readonly;
  4         15318  
  4         4693  
10              
11             our $VERSION = '0.06';
12              
13             # This class converts written note (accepting various syntax for the notes) into
14             # tones (degrees) relative to the key of C4.
15              
16 198     198 0 91890 sub new ($class, %options) {
  198         356  
  198         363  
  198         335  
17             my $self = bless {
18             default_octave => $options{default_octave} // 5, ## no critic (ProhibitMagicNumbers)
19 198   50     1807 key => $options{key} // 'C',
      50        
20             }, $class;
21 198         608 return $self;
22             }
23              
24             # For now we assume that the key is C when entering the sheet music (when
25             # specifying octaves). It’s unclear what are the convention here for other keys.
26              
27             Readonly my %NOTE_TO_TONE => (
28             C => 0,
29             Do => 0,
30             Ut => 0,
31             D => 2,
32             'Re' => 2,
33             'Ré' => 2,
34             E => 4,
35             Mi => 4,
36             F => 5,
37             Fa => 5,
38             G => 7,
39             Sol => 7,
40             A => 9,
41             La => 9,
42             B => 11,
43             Si => 11,
44             H => 11,
45             );
46              
47             # TODO support Unicode representation of the sharp (♯), flat (♭), natural (♮)
48             # and clef (𝄞) signs.
49              
50             Readonly my %ACCIDENTAL_TO_ALTERATION => (
51             '#' => 1,
52             '♯' => 1,
53             '+' => 1,
54             'b' => -1,
55             '-' => -1,
56             '♭' => -1,
57             '♮' => 0,
58             '=' => 0,
59             '' => 0,
60             );
61              
62             Readonly my %SIGNATURE_TO_KEY => (
63             '' => 'C',
64             b => 'F',
65             bb => 'Bb',
66             bbb => 'Eb',
67             bbbb => 'Ab',
68             bbbbb => 'Db',
69             bbbbbb => 'Gb',
70             bbbbbbb => 'Cb',
71             '#' => 'G',
72             '##' => 'D',
73             '###' => 'A',
74             '####' => 'E',
75             '#####' => 'B',
76             '######' => 'F#',
77             '#######' => 'C#',
78             );
79              
80             Readonly my @FLAT_ORDER => qw(B E A D G C F);
81             Readonly my @SHARP_ORDER => qw(F C G D A E B);
82             Readonly my %KEY_TO_ALTERATION => (
83             C => 0,
84             F => -1,
85             Bb => -2,
86             Eb => -3,
87             Ab => -4,
88             Db => -5,
89             Gb => -6,
90             Cb => -7,
91             G => 1,
92             D => 2,
93             A => 3,
94             E => 4,
95             B => 5,
96             'F#' => 6,
97             'C#' => 7,
98             );
99              
100             Readonly my @NOTE_NAMES => qw(do Do ut Ut ré Ré re Re mi Mi fa Fa sol Sol la La si Si);
101             Readonly my $JOINED_NOTE_NAMES => join('|', @NOTE_NAMES);
102             Readonly my $NOTE_NAME_RE => qr/ ${JOINED_NOTE_NAMES} | [A-H] /x;
103             Readonly my $ACCIDENTAL_RE => join '|',
104             map { (quotemeta $_).'+' } grep { $_ ne '' } keys %ACCIDENTAL_TO_ALTERATION;
105              
106             Readonly my $BASE_OCTAVE => 4;
107             Readonly my $TONES_PER_SCALE => 12;
108              
109 1616     1616 0 2714 sub accidental_to_alteration ($acc) {
  1616         2734  
  1616         2370  
110             # We know that $acc is a single character possibly repeated.
111 1616         5159 return $ACCIDENTAL_TO_ALTERATION{substr $acc, 0, 1} * length($acc);
112             }
113              
114 4691     4691 0 39824 sub note_to_tone ($note) {
  4691         8081  
  4691         7321  
115 4691         15594 return $NOTE_TO_TONE{$note};
116             }
117              
118 3027     3027 0 4509 sub alteration_for_note ($self, $note) {
  3027         4990  
  3027         4690  
  3027         4359  
119 3027         9339 my $alt = $KEY_TO_ALTERATION{$self->{key}};
120 3027 100       20497 if ($alt > 0) {
    100          
121 1 50   2   10 return (any { note_to_tone($_) eq note_to_tone($note) } @SHARP_ORDER[0 .. $alt - 1]) ? 1 : 0;
  2         8  
122             } elsif ($alt < 0) {
123 15 100   22   78 return (any { note_to_tone($_) eq note_to_tone($note) } @FLAT_ORDER[0 .. abs($alt) - 1])
  22         50  
124             ? -1
125             : 0;
126             } else {
127 3011         6811 return 0;
128             }
129             }
130              
131             # Note that calls to convert can return nothing. The general pattern is to call
132             # that in a sub passed to a map call.
133              
134 4549     4549 0 29783 sub convert ($self, $symbols) {
  4549         7748  
  4549         11956  
  4549         26312  
135 4549         7162 my @out;
136 4549         12583 pos($symbols) = 0;
137 4549         12510 while (pos($symbols) < length($symbols)) {
138 4752 100       17576 next if $symbols =~ m/\G\h+/gc;
139              
140 4669 100       12809 if ($symbols =~ m/\G(\v+)/gc) {
141 1         5 push @out, $1;
142 1         4 next;
143             }
144              
145 4668 100       11092 if ($symbols =~ m/ \G \# \s* ( .*? (?:\r\n|\n|\r|\v|\z) )/xgc) {
146 3         30 push @out, $1;
147 3         9 next;
148             }
149              
150 4665 100       11993 if ($symbols =~ m/\G(<|>)/gc) {
151 11 100       36 $self->{default_octave} += $1 eq '>' ? 1 : -1;
152 11         24 next;
153             }
154              
155             # TODO: Support specifying the key with a note name, e.g. KF#.
156 4654 100       11246 if ($symbols =~ m/\GK(b{1,7}|#{0,7})?/gc) {
157 10         46 $self->{key} = $SIGNATURE_TO_KEY{$1};
158 10         77 next;
159             }
160              
161             # There is a bug here that A-3 won’t be parsed as the - will be taken for a flat.
162 4644 100       20477 if ($symbols =~ m/\G ( ${NOTE_NAME_RE} ) ( ${ACCIDENTAL_RE} )? ( \d+ )? (,+|’+|'+)?/xgc) {
163             my ($note, $accidental, $octave, $rel_octave) =
164 4643   66     89022 (ucfirst($1), $2, $3 // $self->{default_octave}, $4);
165 4643 100       10662 if ($rel_octave) {
166 3 100       13 $octave += length($rel_octave) * ($rel_octave =~ /,/ ? -1 : 1);
167             }
168 4643         11885 my $base = $TONES_PER_SCALE * ($octave - $BASE_OCTAVE) + note_to_tone($note);
169 4643 100       37664 my $alteration =
170             $accidental ? accidental_to_alteration($accidental) : $self->alteration_for_note($note);
171 4643         15939 push @out, $base + $alteration;
172 4643         15069 next;
173             }
174              
175 1         171 my $pos = pos($symbols);
176 1         4 substr $symbols, $pos, 0, '-->';
177 1         3 $pos++;
178 1         20 die "Invalid syntax in the input music at character ${pos}: ${symbols}\n";
179             }
180 4548 100       16560 return wantarray ? @out : \@out;
181             }
182              
183             1;