File Coverage

blib/lib/Music/Canon.pm
Criterion Covered Total %
statement 197 220 89.5
branch 99 134 73.8
condition 19 41 46.3
subroutine 19 19 100.0
pod 11 12 91.6
total 345 426 80.9


line stmt bran cond sub pod time code
1             # -*- Perl -*-
2             #
3             # Routines for musical canon construction. See also C of the
4             # L module for a command line tool interface to this
5             # code, and the eg/ directory of this module's distribution for other
6             # example scripts.
7             #
8             # Run perldoc(1) on this file for additional documentation.
9              
10             package Music::Canon;
11              
12 6     6   268650 use 5.010000;
  6         15  
13              
14 6     6   21 use List::Util qw/sum/;
  6         5  
  6         391  
15 6     6   2574 use Moo;
  6         54640  
  6         306  
16 6     6   9542 use Music::AtonalUtil (); # Forte Number to interval sets
  6         52532  
  6         192  
17 6     6   2404 use Music::Scales qw/get_scale_nums is_scale/;
  6         19282  
  6         373  
18 6     6   2500 use namespace::clean;
  6         49577  
  6         20  
19 6     6   1008 use Scalar::Util qw/blessed looks_like_number/;
  6         7  
  6         12730  
20              
21             our $VERSION = '2.04';
22              
23             # Array indices for ascending versus descending scales (as some minor
24             # scales are different, depending)
25             my $ASC = 0;
26             my $DSC = 1;
27              
28             my $FORTE_NUMBER_RE;
29              
30             ##############################################################################
31             #
32             # ATTRIBUTES
33              
34             has atonal => (
35             is => 'rw',
36             default => sub { Music::AtonalUtil->new },
37             );
38              
39             has contrary => (
40             is => 'rw',
41             cocerce =>
42             sub { die "contrary needs boolean\n" if !defined $_[0]; $_[0] ? 1 : 0 },
43             default => sub { 1 },
44             reader => 'get_contrary',
45             writer => 'set_contrary',
46             );
47              
48             has DEG_IN_SCALE => (
49             is => 'rw',
50             coerce => sub {
51             die "scale degrees must be integer greater than 1"
52             if !defined $_[0]
53             or !looks_like_number $_[0]
54             or $_[0] < 2;
55             int $_[0];
56             },
57             default => sub {
58             12;
59             },
60             );
61              
62             has modal_chrome => (
63             is => 'rw',
64             coerce => sub {
65             die "modal_chrome needs troolean (-1,0,1)\n" if !defined $_[0];
66             $_[0] <=> 0;
67             },
68             default => sub {
69             0;
70             },
71             reader => 'get_modal_chrome',
72             writer => 'set_modal_chrome',
73             );
74              
75             has modal_hook => (
76             is => 'rw',
77             default => sub {
78             sub { undef }
79             },
80             isa => sub {
81             ref $_[0] eq 'CODE';
82             },
83             );
84              
85             # input tonic pitch for modal_map
86             has modal_in => (
87             is => 'rw',
88             clearer => 1,
89             predicate => 1,
90             );
91              
92             # output tonic pitch for modal_map
93             has modal_out => (
94             is => 'rw',
95             clearer => 1,
96             predicate => 1,
97             );
98              
99             # These have custom setters as support Forte Numbers and other such
100             # cases difficult to put into a simple coerce sub, so the user-facing
101             # setter are really the set_modal_scale_* subs.
102             has modal_scale_in => (
103             is => 'rw',
104             clearer => 1,
105             predicate => 1,
106             );
107             has modal_scale_out => (
108             is => 'rw',
109             clearer => 1,
110             predicate => 1,
111             );
112              
113             has non_octave_scales => (
114             is => 'rw',
115             cocerce => sub {
116             die "non_octave_scales needs boolean\n" if !defined $_[0];
117             $_[0] ? 1 : 0;
118             },
119             default => sub {
120             0;
121             },
122             );
123              
124             has retrograde => (
125             is => 'rw',
126             cocerce =>
127             sub { die "retrograde needs boolean\n" if !defined $_[0]; $_[0] ? 1 : 0 },
128             default => sub { 1 },
129             reader => 'get_retrograde',
130             writer => 'set_retrograde',
131             );
132              
133             has transpose => (
134             is => 'rw',
135             default => sub { 0 },
136             reader => 'get_transpose',
137             writer => 'set_transpose',
138             );
139              
140             ##############################################################################
141             #
142             # METHODS
143              
144             sub BUILD {
145 20     20 0 91 my ( $self, $param ) = @_;
146 20 50       78 with( exists $param->{pitchstyle} ? $param->{pitchstyle} : 'Music::PitchNum' );
147              
148             # as not expected to change much, if at all
149 20         75046 $FORTE_NUMBER_RE = $self->atonal->forte_number_re;
150              
151             # Major scale by default
152 20 50       172 $self->modal_scale_in( [ [qw(2 2 1 2 2 2 1)], [qw(2 2 1 2 2 2 1)] ] )
153             if !$self->has_modal_scale_in;
154 20 50       487 $self->modal_scale_out( [ [qw(2 2 1 2 2 2 1)], [qw(2 2 1 2 2 2 1)] ] )
155             if !$self->has_modal_scale_out;
156             }
157              
158             # One-to-one interval mapping, though with the contrary, retrograde, and
159             # transpose parameters as possible influences on the results.
160             sub exact_map {
161 6     6 1 1399 my $self = shift;
162              
163 6         9 my ( @new_phrase, $prev_in, $prev_out );
164              
165 6 100       16 for my $e ( ref $_[0] eq 'ARRAY' ? @{ $_[0] } : @_ ) {
  2         5  
166 85         52 my $pitch;
167 85 50 33     262 if ( !defined $e ) {
    50          
    50          
168             # presumably rests/silent bits
169 0         0 push @new_phrase, undef;
170 0         0 next;
171             } elsif ( blessed $e and $e->can('pitch') ) {
172 0         0 $pitch = $e->pitch;
173             } elsif ( looks_like_number $e) {
174 85         62 $pitch = $e;
175             } else {
176             # pass through unknowns
177 0         0 push @new_phrase, $e;
178 0         0 next;
179             }
180              
181 85         48 my $new_pitch;
182 85 100       79 if ( !defined $prev_out ) {
183 6         11 my $trans = $self->get_transpose;
184 6 50       14 if ( !looks_like_number($trans) ) {
185 0   0     0 my $transpose_to = $self->pitchnum($trans)
186             // die "pitchnum failed to parse '$trans'\n";
187 0         0 $trans = $transpose_to - $pitch;
188             }
189 6         6 $new_pitch = $pitch + $trans;
190             } else {
191 79         60 my $delta = $pitch - $prev_in;
192 79 100       152 $delta *= -1 if $self->get_contrary;
193 79         56 $new_pitch = $prev_out + $delta;
194             }
195 85         75 push @new_phrase, $new_pitch;
196 85         66 $prev_in = $pitch;
197 85         70 $prev_out = $new_pitch;
198             }
199 6 100       11 @new_phrase = reverse @new_phrase if $self->get_retrograde;
200              
201 6         38 return @new_phrase;
202             }
203              
204             # mostly for compatibility with older versions of this module
205             sub get_modal_pitches {
206 5     5 1 7 my ($self) = @_;
207 5         22 return $self->modal_in, $self->modal_out;
208             }
209              
210             sub get_modal_scale_in {
211 8     8 1 1952 return @{ $_[0]->modal_scale_in };
  8         53  
212             }
213              
214             sub get_modal_scale_out {
215 3     3 1 8 return @{ $_[0]->modal_scale_out };
  3         24  
216             }
217              
218             # Modal interval mapping - determines the number of diatonic steps and
219             # chromatic offset (if any) from the direction and magnitude of the
220             # delta from the previous input pitch via the input scale intervals,
221             # then replays that number of diatonic steps and (if possible) chromatic
222             # offset via the output scale intervals. Ascending vs. descending motion
223             # may be handled by different scale intervals, if a melodic minor or
224             # similar asymmetric interval set is involved. If this sounds tricky and
225             # complicated, it is because it is.
226             sub modal_map {
227 41     41 1 4779 my $self = shift;
228              
229 41         33 my ( $input_tonic, $output_tonic );
230 41 100       131 if ( $self->has_modal_in ) {
231 18   50     39 $input_tonic = $self->pitchnum( $self->modal_in )
232             // die "pitchnum could not convert modal_in '", $self->modal_in,
233             "' to a pitch number\n";
234             }
235 41 100       135 if ( $self->has_modal_out ) {
236 18   50     34 $output_tonic = $self->pitchnum( $self->modal_out )
237             // die "pitchnum could not convert modal_out '", $self->modal_out,
238             "' to a pitch number\n";
239             }
240              
241 41         94 my $input_mode = $self->modal_scale_in;
242             # local copy of the output scale in the event transposition forces a
243             # rotation of the intervals
244 41         43 my $output_mode = $self->modal_scale_out;
245              
246             # but have to wait until have the first pitch as might be transposing
247             # to a note instead of by some number
248 41         26 my $trans;
249 41         34 my $rotate_by = 0;
250 41         28 my $rotate_chrome = 0;
251              
252 41         27 my ( @new_phrase, $prev_in, $prev_out );
253 41         28 my $phrase_index = 0;
254 41 50       88 for my $obj ( ref $_[0] eq 'ARRAY' ? @{ $_[0] } : @_ ) {
  0         0  
255 271         154 my $pitch;
256 271 50 33     846 if ( !defined $obj ) {
    50          
    50          
257             # presumably rests/silent bits
258 0         0 push @new_phrase, undef;
259 0         0 next;
260             } elsif ( blessed $obj and $obj->can('pitch') ) {
261 0         0 $pitch = $obj->pitch;
262             } elsif ( looks_like_number $obj) {
263 271         195 $pitch = $obj;
264             } else {
265             # pass through unknowns
266 0         0 push @new_phrase, $obj;
267 0         0 next;
268             }
269              
270 271         152 my $new_pitch;
271 271 100 100     615 if ( defined $prev_in and $pitch == $prev_in ) {
272             # oblique motion optimization (a repeated note): just copy previous
273 4         3 $new_pitch = $prev_out;
274              
275             } else {
276             # Interval sets are useless without being tied to some pitch,
277             # assume this is the first note of the phrase if not already set.
278 267 100       296 $input_tonic = $pitch unless defined $input_tonic;
279              
280             # NOTE output tonic is not longer set based on transposed pitch
281             # (as of v1.00); use set_modal_pitches() to specify as necessary.
282             # This change motivated by transpose not really working
283             # everywhere. Instead, output tonic by default is the same as the
284             # input tonic (so the input and output modes share the same root
285             # pitch by default).
286 267 100       282 $output_tonic = $input_tonic unless defined $output_tonic;
287              
288 267 100       272 if ( !defined $trans ) {
289 41         54 $trans = $self->get_transpose;
290 41 100       61 if ( !looks_like_number($trans) ) {
291             # Letter note: "transpose to 'A'" instead of "transpose by N"
292 1   50     4 my $transpose_to = $self->pitchnum($trans)
293             // die 'pitchnum failed to parse ' . $self->transpose . "\n";
294 1         43 $trans = $transpose_to - $pitch;
295             }
296              
297 41 100       62 if ( $trans != 0 ) {
298             # Steps must be from input tonic to first note of phrase plus
299             # transposition, as if in Bflat-Major if one has a phrase that
300             # begins on "D" being moved to "Eflat" that transposition is
301             # modal, and not chromatic.
302 3         8 ( $rotate_by, $rotate_chrome ) =
303             ( $self->steps( $input_tonic, $input_tonic + $trans, $input_mode->[$ASC] ) )
304             [ 0, 1 ];
305             # inverted due to how M::AU->rotate works
306 3         4 $rotate_by *= -1;
307              
308 3 50       5 if ( $rotate_chrome != 0 ) {
309 0         0 die "transpose to chromatic pitch unsupported by modal_map()";
310             }
311              
312             # Transpositions require rotation of the output mode to match
313             # where the starting pitch of the phrase lies in the output
314             # mode, as otherwise for c-minor to c-minor, transposing from
315             # C to E-flat, would for an input phrase of C->Bb->Ab get the
316             # C->Bb->Ab intervals instead of those for Eb->D->C. That is,
317             # the output would become E-flat minor by virtue of the
318             # transposition without the rotation done here.
319 3 50       6 if ( $rotate_by != 0 ) {
320 3         12 $output_mode->[$ASC] =
321             $self->atonal->rotate( $rotate_by, $output_mode->[$ASC] );
322 3         66 $output_mode->[$DSC] =
323             $self->atonal->rotate( $rotate_by, $output_mode->[$DSC] );
324             }
325             }
326             }
327              
328             # Determine whether input must be figured on the ascending or
329             # descending scale intervals; descending intervals only if there
330             # is a previous pitch and if the delta from that previous pitch
331             # shows descending motion, otherwise ascending. The scales are
332             # [[asc],[dsc]] AoA.
333 267         191 my $input_motion = $ASC;
334 267 100 100     632 $input_motion = $DSC if defined $prev_in and $pitch - $prev_in < 0;
335 267 100       342 my $output_motion = $self->get_contrary ? !$input_motion : $input_motion;
336              
337             # Magnitude of interval from tonic, and whether above or below the
338             # tonic (as if below, must walk scale intervals backwards).
339 267         304 my ( $steps, $chromatic_offset, $is_dsc, $last_input_interval ) =
340             $self->steps( $input_tonic, $pitch, $input_mode->[$input_motion] );
341              
342             # Contrary motion means not only the opposite scale intervals,
343             # but the opposite direction through those intervals (in
344             # melodic minor, ascending motion in ascending intervals (C to
345             # Eflat) corresponds to descending motion in descending
346             # intervals (C to Aflat).
347 267 100       416 $is_dsc = !$is_dsc if $self->get_contrary;
348              
349 267         165 my $output_interval = 0;
350              
351             # Replay the same number of diatonic steps using the appropriate
352             # output intervals and direction of interval iteration, plus
353             # chromatic adjustments, if any.
354 267         152 my $idx;
355 267 100       294 if ($steps) {
356 234         142 $steps--;
357 234         237 for my $s ( 0 .. $steps ) {
358 1095         584 $idx = $s % @{ $output_mode->[$output_motion] };
  1095         779  
359 1095 100       1107 $idx = $#{ $output_mode->[$output_motion] } - $idx if $is_dsc;
  707         528  
360 1095         873 $output_interval += $output_mode->[$output_motion][$idx];
361             }
362             }
363              
364 267         151 my $hooked = 0;
365 267 100       289 if ( $chromatic_offset != 0 ) {
366 85         68 my $step_interval = $output_mode->[$output_motion][$idx];
367 85 50       89 my $step_dir = $step_interval < 0 ? -1 : 1;
368 85         55 $step_interval = abs $step_interval;
369              
370 85 100       87 if ( $chromatic_offset >= $step_interval ) {
371             # Whoops, chromatic does not fit into output scale. Punt to hook
372             # function to handle everything for this pitch.
373 17         314 $new_pitch = $self->modal_hook->(
374             $output_interval,
375             chromatic_offset => $chromatic_offset,
376             phrase_index => $phrase_index,
377             scale => $output_mode->[$output_motion],
378             scale_index => $idx,
379             step_dir => $step_dir,
380             step_interval => $step_interval,
381             );
382 17         22 $hooked = 1;
383             } else {
384 68 100       67 if ( $step_interval == 2 ) {
385             # only one possible chromatic fits
386 63         58 $output_interval -= $step_dir * $chromatic_offset;
387             } else {
388             # modal_chrome is a troolean - either a literal chromatic
389             # going up or down if positive or negative, otherwise if 0
390             # try to figure out something proportional to where the
391             # chromatic was between the diatonics of the input scale.
392 5 100       15 if ( $self->get_modal_chrome > 0 ) {
    100          
393 1         3 $output_interval -= $step_dir * $chromatic_offset;
394             } elsif ( $self->get_modal_chrome < 0 ) {
395 1         3 $output_interval += $step_dir * ( $chromatic_offset - $step_interval );
396             } else {
397 3         8 my $fraction = sprintf "%.0f",
398             $step_interval * $chromatic_offset / $last_input_interval;
399 3         6 $output_interval += $step_dir * ( $fraction - $step_interval );
400             }
401             }
402             }
403             }
404              
405 267 100       299 if ( !$hooked ) {
406 250 100       285 $output_interval = int( $output_interval * -1 ) if $is_dsc;
407 250         214 $new_pitch = $output_tonic + $trans + $output_interval;
408             }
409             }
410              
411 271         216 push @new_phrase, $new_pitch;
412 271         179 $prev_in = $pitch;
413 271         135 $prev_out = $new_pitch;
414              
415 271         237 $phrase_index++;
416             }
417 41 100       84 @new_phrase = reverse @new_phrase if $self->get_retrograde;
418              
419 41         223 return @new_phrase;
420             }
421              
422             sub reset_modal_pitches {
423 2     2 1 28 $_[0]->clear_modal_in;
424 2         302 $_[0]->clear_modal_out;
425             }
426              
427             # Mostly for compatibility with how older versions of this module
428             # worked, and handy to do these in a single call.
429             sub set_modal_pitches {
430 17     17 1 4078 my ( $self, $input_pitch, $output_pitch ) = @_;
431              
432 17         12 my $pitch;
433 17 50       35 if ( defined $input_pitch ) {
434 17   50     36 $pitch = $self->pitchnum($input_pitch)
435             // die "pitchnum failed to parse $input_pitch\n";
436 17         92 $self->modal_in($pitch);
437             # Auto-reset output if something prior there so not carrying along
438             # something from a previous conversion, as the default is to use the
439             # same pitch for the output tonic as from the input.
440 17 50 33     33 if ( !defined $output_pitch and $self->has_modal_out ) {
441 0         0 $self->clear_modal_out;
442             }
443             }
444 17 50       24 if ( defined $output_pitch ) {
445 17   50     28 $pitch = $self->pitchnum($output_pitch)
446             // die "pitchnum failed to parse $output_pitch\n";
447 17         74 $self->modal_out($pitch);
448             }
449             }
450              
451             sub set_modal_scale_in {
452 5     5 1 57 my $self = shift;
453 5         15 $self->modal_scale_in( $self->scales2intervals(@_) );
454             }
455              
456             sub set_modal_scale_out {
457 17     17 1 48 my $self = shift;
458 17         27 $self->modal_scale_out( $self->scales2intervals(@_) );
459             }
460              
461             sub scales2intervals {
462 22     22 1 24 my ( $self, $asc, $dsc ) = @_;
463 22 0 33     45 if ( !defined $asc and !defined $dsc ) {
464 0         0 die "must define one of asc or dsc or both";
465             }
466              
467 22         19 my @intervals;
468 22         16 my $is_scale = 0;
469 22 50       35 if ( defined $asc ) {
470 22 100       116 if ( ref $asc eq 'ARRAY' ) {
    100          
471             # Assume arbitrary list of intervals as integers if array ref
472 15         18 for my $n (@$asc) {
473 65 50 33     276 die "ascending intervals must be positive integers"
474             unless looks_like_number $n and $n =~ m/^[+]?[0-9]+$/;
475             }
476 15         35 $intervals[$ASC] = [@$asc];
477              
478             } elsif ( $asc =~ m/($FORTE_NUMBER_RE)/ ) {
479             # derive scale intervals from pitches of the named Forte Number
480 3         14 my $pset = $self->atonal->forte2pcs($1);
481 3 50       21 die "no Forte Number parsed from ascending '$asc'" unless defined $pset;
482 3         21 $intervals[$ASC] = $self->atonal->pcs2intervals($pset);
483              
484             } else {
485 4 50       27 die "ascending scale '$asc' unknown to Music::Scales"
486             unless is_scale($asc);
487 4         40 my @asc_nums = get_scale_nums($asc);
488 4         70 my @dsc_nums;
489 4 50       14 @dsc_nums = get_scale_nums( $asc, 1 ) unless defined $dsc;
490              
491 4         47 $intervals[$ASC] = [];
492 4         11 for my $i ( 1 .. $#asc_nums ) {
493 24         17 push @{ $intervals[$ASC] }, $asc_nums[$i] - $asc_nums[ $i - 1 ];
  24         37  
494             }
495 4 50       10 if (@dsc_nums) {
496 4         7 $intervals[$DSC] = [];
497 4         9 for my $i ( 1 .. $#dsc_nums ) {
498 24         14 unshift @{ $intervals[$DSC] }, $dsc_nums[ $i - 1 ] - $dsc_nums[$i];
  24         40  
499             }
500             }
501 4         8 $is_scale = 1;
502             }
503             }
504              
505 22 100       74 if ( !defined $dsc ) {
506             # Assume descending equals ascending (true in most cases, except
507             # melodic minor and similar), unless a scale was involved, as the
508             # Music::Scales code should already have setup the descending bit.
509 20 100       41 $intervals[$DSC] = $intervals[$ASC] unless $is_scale;
510             } else {
511 2 100       33 if ( ref $dsc eq 'ARRAY' ) {
    50          
512 1         2 for my $n (@$dsc) {
513 6 50 33     26 die "descending intervals must be positive integers"
514             unless looks_like_number $n and $n =~ m/^[+]?[0-9]+$/;
515             }
516 1         3 $intervals[$DSC] = [@$dsc];
517              
518             } elsif ( $dsc =~ m/($FORTE_NUMBER_RE)/ ) {
519             # derive scale intervals from pitches of the named Forte Number
520 1         4 my $pset = $self->atonal->forte2pcs($1);
521 1 50       7 die "no Forte Number parsed from descending '$dsc'" unless defined $pset;
522 1         4 $intervals[$DSC] = $self->atonal->pcs2intervals($pset);
523              
524             } else {
525 0 0       0 die "descending scale '$dsc' unknown to Music::Scales"
526             unless is_scale($dsc);
527 0         0 my @dsc_nums = get_scale_nums( $dsc, 1 );
528              
529 0         0 $intervals[$DSC] = [];
530 0         0 for my $i ( 1 .. $#dsc_nums ) {
531 0         0 unshift @{ $intervals[$DSC] }, $dsc_nums[ $i - 1 ] - $dsc_nums[$i];
  0         0  
532             }
533             }
534             }
535              
536             # Complete scales to sum to 12 by default (Music::Scales omits the VII
537             # to I interval, and who knows what a custom list would contain).
538 22 100       54 if ( !$self->non_octave_scales ) {
539 20         24 for my $ref (@intervals) {
540 40   50     210 my $sum = sum(@$ref) // 0;
541 40 50       53 die "empty interval set\n" if $sum == 0;
542 40 100       546 if ( $sum < $self->DEG_IN_SCALE ) {
    50          
543 25         1711 push @$ref, $self->DEG_IN_SCALE - $sum;
544             } elsif ( $sum > $self->DEG_IN_SCALE ) {
545 0         0 die "non-octave scales require non_octave_scales param";
546             }
547             }
548             }
549              
550 22         323 return \@intervals;
551             }
552              
553             sub steps {
554 272     272 1 245 my ( $self, $from, $to, $scale ) = @_;
555              
556 272 50       411 die "from pitch must be a number\n" if !looks_like_number $from;
557 272 50       371 die "to pitch must be a number\n" if !looks_like_number $to;
558 272 50 33     723 die "scales must be reference to two array ref of intervals\n"
559             if !defined $scale
560             or ref $scale ne 'ARRAY';
561              
562 272         193 my $delta = $to - $from;
563 272 100       260 my $dir = $delta < 0 ? $DSC : $ASC;
564 272         161 $delta = abs $delta;
565              
566 272         186 my $running_total = 0;
567 272         162 my $steps = 0;
568 272         149 my $index = 0;
569 272         321 while ( $running_total < $delta ) {
570 1105         717 $index = $steps++ % @$scale;
571 1105 100       1117 $index = $#{$scale} - $index if $dir == $DSC;
  414         281  
572 1105         1266 $running_total += $scale->[$index];
573             }
574              
575 272         374 return $steps, $running_total - $delta, $dir, $scale->[$index];
576             }
577              
578             1;
579             __END__