File Coverage

blib/lib/Music/Scala.pm
Criterion Covered Total %
statement 212 227 93.3
branch 109 156 69.8
condition 14 26 53.8
subroutine 27 27 100.0
pod 17 18 94.4
total 379 454 83.4


line stmt bran cond sub pod time code
1             # -*- Perl -*-
2             #
3             # Scala scale (musical tuning and temperament) support for Perl, based
4             # on specification at: http://www.huygens-fokker.org/scala/
5             #
6             # Ratio to cent and cent to ratio equations lifted from "Musimathics,
7             # volume 1", pp. 45-46. MIDI conversion probably from wikipedia.
8              
9             package Music::Scala;
10              
11 2     2   70780 use 5.010000;
  2         14  
12 2     2   10 use strict;
  2         4  
  2         38  
13 2     2   9 use warnings;
  2         3  
  2         67  
14              
15 2     2   21 use Carp qw/croak/;
  2         5  
  2         116  
16 2     2   13 use File::Basename qw/basename/;
  2         4  
  2         162  
17 2     2   1146 use Moo;
  2         24355  
  2         8  
18 2     2   4032 use namespace::clean;
  2         25105  
  2         14  
19 2     2   604 use Scalar::Util qw/looks_like_number reftype/;
  2         5  
  2         6622  
20              
21             our $VERSION = '1.05';
22              
23             ##############################################################################
24             #
25             # ATTRIBUTES
26             #
27             # NOTE that much of the Moo setup (getters/setters, how "notes" handled,
28             # etc) is to preserve compatibility with how the code worked pre-Moo.
29             # Additional hilarity stemmed from (the mistake of?) offering multiple
30             # methods to get/set the same data in different guises (notes (as cents
31             # or ratios), (notes as) cents, (notes as) ratios).
32              
33             has binmode => (
34             is => 'rw',
35             predicate => 1, # has_binmode
36             reader => 'get_binmode',
37             writer => 'set_binmode',
38             );
39              
40             has concertfreq => (
41             is => 'rw',
42             default => sub { 440 },
43             isa => sub {
44             die 'frequency must be a positive number (Hz)'
45             if !defined $_[0]
46             or !looks_like_number $_[0]
47             or $_[0] <= 0;
48             },
49             reader => 'get_concertfreq',
50             writer => 'set_concertfreq',
51             );
52              
53             has concertpitch => (
54             is => 'rw',
55             default => sub { 69 },
56             isa => sub {
57             die 'pitch must be a positive number'
58             if !defined $_[0]
59             or !looks_like_number $_[0]
60             or $_[0] <= 0;
61             },
62             reader => 'get_concertpitch',
63             writer => 'set_concertpitch',
64             );
65              
66             has description => (
67             is => 'rw',
68             default => sub { '' },
69             isa => sub {
70             die 'description must be string value'
71             if !defined $_[0]
72             or defined reftype $_[0];
73             },
74             reader => 'get_description',
75             writer => 'set_description',
76             );
77              
78             # Sanity on scala scale file reads; other prudent limits with untrusted
79             # input would be to check the file size, and perhaps to bail if the note
80             # count is some absurd value.
81             has MAX_LINES => (
82             is => 'rw',
83             default => sub { 3000 },
84             );
85              
86             has notes => (
87             is => 'rw',
88             clearer => 1,
89             predicate => 1, # has_notes
90             );
91              
92             ##############################################################################
93             #
94             # METHODS
95              
96             sub BUILD {
97 6     6 0 71 my ($self, $param) = @_;
98              
99 6 50 66     35 if (exists $param->{file} and exists $param->{fh}) {
100 0         0 die "new accepts only one of the 'file' or 'fh' arguments\n";
101             }
102              
103 6 100       60 if (exists $param->{file}) {
    50          
104 1         6 $self->read_scala(file => $param->{file});
105             } elsif (exists $param->{fh}) {
106 0         0 $self->read_scala(fh => $param->{fh});
107             }
108             }
109              
110             # Absolute interval list to relative (1 2 3 -> 1 1 1)
111             sub abs2rel {
112 2     2 1 1528 my $self = shift;
113 2 50       7 return if !@_;
114 2         6 my @result = $_[0];
115 2 50       7 if (@_ > 1) {
116 2         7 for my $i (1 .. $#_) {
117 13         52 push @result, $_[$i] - $_[ $i - 1 ];
118             }
119             }
120 2         14 return @result;
121             }
122              
123             sub cents2ratio {
124 2     2 1 6 my ($self, $cents, $precision) = @_;
125 2 50       10 croak 'cents must be a number' if !looks_like_number $cents;
126 2 100       6 if (defined $precision) {
127 1 50 33     10 croak 'precision must be a positive integer'
128             if !looks_like_number $precision or $precision < 0;
129 1         3 $precision = int $precision;
130             } else {
131 1         3 $precision = 2;
132             }
133              
134 2         22 return sprintf "%.*f", $precision, 10**($cents / 3986.31371386484);
135             }
136              
137             # MIDI calculation, for easy comparison to scala results
138             sub freq2pitch {
139 1     1 1 734 my ($self, $freq) = @_;
140 1 50 33     10 croak 'frequency must be a positive number'
141             if !looks_like_number $freq
142             or $freq <= 0;
143              
144             # no precision, as assume pitch numbers are integers
145 1         17 return sprintf '%.0f',
146             $self->get_concertpitch +
147             12 * (log($freq / $self->get_concertfreq) / 0.693147180559945);
148             }
149              
150             sub get_cents {
151 3     3 1 1008 my ($self) = @_;
152 3 50       15 croak 'no scala loaded' if !$self->has_notes;
153 3         6 return $self->notes2cents(@{ $self->notes });
  3         16  
154             }
155              
156             sub get_notes {
157 6     6 1 4433 my ($self) = @_;
158 6 100       44 croak 'no scala loaded' if !$self->has_notes;
159 5         11 return @{ $self->notes };
  5         51  
160             }
161              
162             sub get_ratios {
163 3     3 1 1054 my ($self) = @_;
164 3 50       14 croak 'no scala loaded' if !$self->has_notes;
165 3         7 return $self->notes2ratios(@{ $self->notes });
  3         10  
166             }
167              
168             sub interval2freq {
169 5     5 1 16 my $self = shift;
170 5 50       23 croak 'no scala loaded' if !$self->has_notes;
171              
172 5         10 my @ratios = $self->notes2ratios(@{ $self->notes });
  5         20  
173              
174 5         9 my @freqs;
175 5 50       18 for my $i (ref $_[0] eq 'ARRAY' ? @{ $_[0] } : @_) {
  0         0  
176 79 100       130 if ($i == 0) { # special case for unison (ratio 1/1)
177 5         17 push @freqs, $self->get_concertfreq;
178             } else {
179 74 100       112 my $is_dsc = $i < 0 ? 1 : 0;
180              
181             # for non-"octave" portion, if any
182 74         103 my $offset = $i % @ratios;
183              
184             # "Octave" portion, if any - how many times the interval
185             # passes through the complete scale
186 74         99 my $octave_freq = 0;
187 74         122 my $octave_count = abs int $i / @ratios;
188              
189             # if non-octave on a negative interval, go one octave past
190             # the target, then use the regular ascending logic to
191             # backtrack to the proper frequency
192 74 100 100     172 $octave_count++ if $is_dsc and $offset != 0;
193              
194 74 100       127 if ($octave_count > 0) {
195 58         98 my $octaves_ratio = $ratios[-1]**$octave_count;
196 58 100       94 $octaves_ratio = 1 / $octaves_ratio if $is_dsc;
197 58         114 $octave_freq = $self->get_concertfreq * $octaves_ratio;
198             }
199              
200 74         95 my $remainder_freq = 0;
201 74 100       119 if ($offset != 0) {
202 59   66     129 $remainder_freq =
203             ($octave_freq || $self->get_concertfreq) * $ratios[ $offset - 1 ];
204              
205             # zero as remainder is based from $octave_freq, if
206             # relevant, so already includes such
207 59         73 $octave_freq = 0;
208             }
209              
210 74         137 push @freqs, $octave_freq + $remainder_freq;
211             }
212             }
213              
214 5         27 return @freqs;
215             }
216              
217             sub is_octavish {
218 2     2 1 16 my $self = shift;
219 2 50       11 croak 'no scala loaded' if !$self->has_notes;
220              
221 2         5 my @ratios = $self->notes2ratios(@{ $self->notes });
  2         10  
222              
223             # not octave bounded (double the frequency, e.g. 440 to 880)
224 2 100       17 return 0 if $ratios[-1] != 2;
225              
226 1         3 my $min;
227 1         4 for my $r (@ratios) {
228             # don't know how to handle negative ratios
229 12 50       21 return 0 if $r < 0;
230              
231             # multiple scales within the same definition file (probably for
232             # instruments that have two different scales in the same
233             # frequency domain) - but don't know how to handle these
234 12 50 66     34 return 0 if defined $min and $r <= $min;
235              
236 12         15 $min = $r;
237             }
238              
239 1         7 return 1;
240             }
241              
242             sub notes2cents {
243 17     17 1 33 my $self = shift;
244              
245 17         30 my @cents;
246 17 50       59 for my $n (ref $_[0] eq 'ARRAY' ? @{ $_[0] } : @_) {
  0         0  
247 42 100       162 if ($n =~ m{([0-9]+)/([1-9][0-9]*)}) {
248 20         124 push @cents, 1200 * ((log($1 / $2) / 2.30258509299405) / 0.301029995663981);
249             } else {
250 22         43 push @cents, $n;
251             }
252             }
253              
254 17         105 return @cents;
255             }
256              
257             sub notes2ratios {
258 11     11 1 22 my $self = shift;
259              
260 11         19 my @ratios;
261 11 50       38 for my $n (ref $_[0] eq 'ARRAY' ? @{ $_[0] } : @_) {
  0         0  
262 82 100       216 if ($n =~ m{([0-9]+)/([1-9][0-9]*)}) {
263 25         78 push @ratios, $1 / $2; # ratio, as marked with /
264             } else {
265 57         144 push @ratios, 10**($n / 3986.31371386484);
266             }
267             }
268              
269 11         41 return @ratios;
270             }
271              
272             # MIDI for comparison, the other way
273             sub pitch2freq {
274 54     54 1 316 my ($self, $pitch) = @_;
275 54 50 33     184 croak "pitch must be MIDI number"
276             if !looks_like_number $pitch
277             or $pitch < 0;
278              
279 54         391 return $self->get_concertfreq * (2**(($pitch - $self->get_concertpitch) / 12));
280             }
281              
282             sub ratio2cents {
283 2     2 1 1033 my ($self, $ratio, $precision) = @_;
284 2 50       10 croak 'ratio must be a number' if !looks_like_number $ratio;
285 2 100       8 if (defined $precision) {
286 1 50 33     9 croak 'precision must be a positive integer'
287             if !looks_like_number $precision or $precision < 0;
288 1         3 $precision = int $precision;
289             } else {
290 1         3 $precision = 2;
291             }
292              
293 2         29 return sprintf "%.*f", $precision,
294             1200 * ((log($ratio) / 2.30258509299405) / 0.301029995663981);
295             }
296              
297             sub read_scala {
298 10     10 1 6315 my $self = shift;
299 10         19 my %param;
300 10 100       34 if (@_ == 1) {
301 1         4 $param{file} = $_[0];
302             } else {
303 9         34 %param = @_;
304             }
305              
306 10         18 my $fh;
307 10 50       29 if (exists $param{file}) {
    0          
308 10 100       697 open($fh, '<', $param{file}) or croak 'open failed: ' . $!;
309             } elsif (exists $param{fh}) {
310 0         0 $fh = $param{fh};
311             } else {
312 0         0 croak 'must specify file or fh parameter to read_scala';
313             }
314 9 100       74 if (exists $param{binmode}) {
    100          
315 1 50   1   39 binmode $fh, $param{binmode} or croak 'binmode failed: ' . $!;
  1         8  
  1         2  
  1         6  
316             } elsif ($self->has_binmode) {
317 5 50       81 binmode $fh, $self->get_binmode or croak 'binmode failed: ' . $!;
318             }
319              
320 9         13286 my (@scala, $line_count);
321 9         307 while (!eof($fh)) {
322 29         94 my $line = readline $fh;
323 29 50       61 croak 'readline failed: ' . $! unless defined $line;
324 29 100       112 croak 'input exceeds MAX_LINES' if ++$line_count >= $self->MAX_LINES;
325 28 100       103 next if $line =~ m/^[!]/; # skip comments
326              
327 16         32 chomp $line;
328 16         39 push @scala, $line;
329              
330 16 100       48 last if @scala == 2;
331             }
332             # but as might hit the MAX_LINES or eof() instead check again...
333 8 50       32 if (@scala != 2) {
334 0         0 croak 'missing description or note count lines';
335             }
336              
337 8         261 $self->set_description(shift @scala);
338 8         78 my $NOTECOUNT;
339 8 50       39 if ($scala[-1] =~ m/^\s*([0-9]+)/) {
340 8         23 $NOTECOUNT = $1;
341             } else {
342 0         0 croak 'could not parse note count';
343             }
344              
345 8         14 my @notes;
346 8         16 my $cur_note = 1;
347 8         24 while (!eof($fh)) {
348 94         158 my $line = readline $fh;
349 94 50       157 croak 'readline failed: ' . $! unless defined $line;
350 94 50       190 croak 'input exceeds MAX_LINES' if ++$line_count >= $self->MAX_LINES;
351 94 100       184 next if $line =~ m/^[!]/; # skip comments
352              
353             # All the scales.zip *.scl files as of 2013-02-19 have digits on
354             # both sides of the dot (so there are no ".42" cent values, but
355             # the "these are all valid pitch lines" does include a "408." as
356             # allowed). Some scale files have negative cents, though that is
357             # illegal for ratios. All the ratios are plain numbers (no
358             # period), or if they have a slash, it is followed by another
359             # number (so no "42/" cases). Checked via various greps on the
360             # file contents.
361 88 100       328 if ($line =~ m/^\s* ( -?[0-9]+\. [0-9]* ) /x) {
    50          
    50          
362 51         121 push @notes, $1; # cents
363             } elsif ($line =~ m{^\s* -[0-9] }x) {
364             # specification says these "should give a read error"
365 0         0 croak 'invalid negative ratio in note list';
366             } elsif ($line =~ m{^\s* ( [1-9][0-9]* (?:/[0-9]+)? ) }x) {
367 37         79 my $ratio = $1;
368 37 100       79 $ratio .= '/1' if $ratio !~ m{/}; # implicit qualify of ratios
369 37         73 push @notes, $ratio;
370             } else {
371             # Nothing in the spec about non-matching lines, so blow up.
372             # However, there are six files in scales.zip that have
373             # trailing blank lines, though these blank lines occur only
374             # after an appropriate number of note entries. So must exit
375             # loop before reading those invalid? lines. (Did mail the
376             # author about these, so probably has been rectified.)
377 0         0 croak 'invalid note specification on line ' . $.;
378             }
379              
380 88 100       215 last if $cur_note++ >= $NOTECOUNT;
381             }
382 8 50       21 if (@notes != $NOTECOUNT) {
383 0         0 croak 'expected ' . $NOTECOUNT . ' notes but got ' . scalar(@notes) . " notes";
384             }
385              
386             # edge case: remove any 1/1 (zero cents) at head of the list, as
387             # this implementation treats that as implicit
388 8 100       35 shift @notes if sprintf("%.0f", $self->notes2cents($notes[0])) == 0;
389              
390 8         42 $self->notes(\@notes);
391              
392 8         205 return $self;
393             }
394              
395             # Relative interval list to absolute (1 1 1 -> 1 2 3)
396             sub rel2abs {
397 2     2 1 5 my $self = shift;
398 2 50       7 return if !@_;
399 2         6 my @result = $_[0];
400 2 50       6 if (@_ > 1) {
401 2         7 for my $i (1 .. $#_) {
402 13         25 push @result, $result[-1] + $_[$i];
403             }
404             }
405 2         12 return @result;
406             }
407              
408             # Given list of frequencies, assume first is root frequency, then
409             # convert the remainder of the frequencies to cents against that first
410             # frequency.
411             sub set_by_frequency {
412 2     2 1 914 my $self = shift;
413 2 100       9 my $freqs = ref $_[0] eq 'ARRAY' ? $_[0] : \@_;
414 2 50       8 croak 'need both root and other frequencies' if @$freqs < 2;
415 2 50       8 croak 'root frequency must not be zero' if $freqs->[0] == 0;
416              
417 2         3 my @notes;
418 2         4 for my $i (1 .. $#{$freqs}) {
  2         8  
419 3         15 push @notes,
420             1200 *
421             ((log($freqs->[$i] / $freqs->[0]) / 2.30258509299405) / 0.301029995663981);
422             }
423              
424             # edge case: remove any 1/1 (zero cents) at head of the list, as
425             # this implementation treats that as implicit
426 2 50       7 shift @notes if sprintf("%.0f", $self->notes2cents($notes[0])) == 0;
427              
428 2         10 $self->notes(\@notes);
429              
430 2         9 return $self;
431             }
432              
433             sub set_notes {
434 3     3 1 1088 my $self = shift;
435 3         9 my @notes;
436 3 100       16 for my $n (ref $_[0] eq 'ARRAY' ? @{ $_[0] } : @_) {
  1         4  
437 10 100       67 if ($n =~ m{^ -?[0-9]+\. (?:[0-9]+)? $}x) {
    50          
438 6         13 push @notes, $n;
439             } elsif ($n =~ m{^ [1-9][0-9]* (?:/[0-9]+)? $}x) {
440 4         9 my $ratio = $n;
441 4 50       15 $ratio .= '/1' if $ratio !~ m{/}; # implicit qualify of ratios
442 4         11 push @notes, $ratio;
443             } else {
444 0         0 croak 'notes must be integer ratios or real numbers';
445             }
446             }
447              
448             # edge case: remove any 1/1 (zero cents) at head of the list, as
449             # this implementation treats that as implicit
450 3 50       12 shift @notes if sprintf("%.0f", $self->notes2cents($notes[0])) == 0;
451              
452 3         18 $self->notes(\@notes);
453 3         14 return $self;
454             }
455              
456             sub write_scala {
457 2     2 1 2812 my $self = shift;
458 2 50       33 croak 'no scala loaded' if !$self->has_notes;
459              
460 2         6 my %param;
461 2 50       11 if (@_ == 1) {
462 0         0 $param{file} = $_[0];
463             } else {
464 2         10 %param = @_;
465             }
466              
467 2         33 my $fh;
468 2 100       10 if (exists $param{file}) {
    50          
469 1 50       227 open($fh, '>', $param{file}) or croak 'open failed: ' . $!;
470             } elsif (exists $param{fh}) {
471 1         4 $fh = $param{fh};
472             } else {
473 0         0 croak 'must specify file or fh parameter to write_scala';
474             }
475 2 100       14 if (exists $param{binmode}) {
    50          
476 1 50       22 binmode $fh, $param{binmode} or croak 'binmode failed: ' . $!;
477             } elsif ($self->has_binmode) {
478 1 50       14 binmode $fh, $self->get_binmode or croak 'binmode failed: ' . $!;
479             }
480              
481             my $filename = basename($param{file})
482 2 100       154 if exists $param{file};
483 2   50     7 my $note_count = @{ $self->notes } || 0;
484              
485 2 100       28 say $fh defined $filename
486             ? "! $filename"
487             : '!';
488 2         7 say $fh '!';
489 2         11 say $fh $self->get_description;
490 2         11 say $fh ' ', $note_count;
491 2         6 say $fh '!'; # conventional comment between note count and notes
492              
493 2         5 for my $note (@{ $self->notes }) {
  2         7  
494 14         25 say $fh ' ', $note;
495             }
496              
497 2         100 return $self;
498             }
499              
500             1;
501             __END__