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   109577 use 5.010000;
  6         18  
13              
14 6     6   30 use List::Util qw/sum/;
  6         10  
  6         740  
15 6     6   3582 use Moo;
  6         74913  
  6         40  
16 6     6   12616 use Music::AtonalUtil (); # Forte Number to interval sets
  6         76259  
  6         281  
17 6     6   3721 use Music::Scales qw/get_scale_nums is_scale/;
  6         38235  
  6         488  
18 6     6   3312 use namespace::clean;
  6         69636  
  6         29  
19 6     6   1329 use Scalar::Util qw/blessed looks_like_number/;
  6         11  
  6         17106  
20              
21             our $VERSION = '2.03';
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 19     19 0 112 my ( $self, $param ) = @_;
146 19 50       93 with( exists $param->{pitchstyle} ? $param->{pitchstyle} : 'Music::PitchNum' );
147              
148             # as not expected to change much, if at all
149 19         109638 $FORTE_NUMBER_RE = $self->atonal->forte_number_re;
150              
151             # Major scale by default
152 19 50       248 $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 19 50       1207 $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 1489 my $self = shift;
162              
163 6         7 my ( @new_phrase, $prev_in, $prev_out );
164              
165 6 100       22 for my $e ( ref $_[0] eq 'ARRAY' ? @{ $_[0] } : @_ ) {
  2         5  
166 85         55 my $pitch;
167 85 50 33     288 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         66 $pitch = $e;
175             } else {
176             # pass through unknowns
177 0         0 push @new_phrase, $e;
178 0         0 next;
179             }
180              
181 85         55 my $new_pitch;
182 85 100       89 if ( !defined $prev_out ) {
183 6         15 my $trans = $self->get_transpose;
184 6 50       13 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         10 $new_pitch = $pitch + $trans;
190             } else {
191 79         60 my $delta = $pitch - $prev_in;
192 79 100       116 $delta *= -1 if $self->get_contrary;
193 79         59 $new_pitch = $prev_out + $delta;
194             }
195 85         65 push @new_phrase, $new_pitch;
196 85         59 $prev_in = $pitch;
197 85         75 $prev_out = $new_pitch;
198             }
199 6 100       15 @new_phrase = reverse @new_phrase if $self->get_retrograde;
200              
201 6         40 return @new_phrase;
202             }
203              
204             # mostly for compatibility with older versions of this module
205             sub get_modal_pitches {
206 5     5 1 10 my ($self) = @_;
207 5         32 return $self->modal_in, $self->modal_out;
208             }
209              
210             sub get_modal_scale_in {
211 8     8 1 3406 return @{ $_[0]->modal_scale_in };
  8         74  
212             }
213              
214             sub get_modal_scale_out {
215 3     3 1 8 return @{ $_[0]->modal_scale_out };
  3         31  
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 5135 my $self = shift;
228              
229 41         42 my ( $input_tonic, $output_tonic );
230 41 100       172 if ( $self->has_modal_in ) {
231 18   50     50 $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       157 if ( $self->has_modal_out ) {
236 18   50     41 $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         114 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         54 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         38 my $trans;
249 41         34 my $rotate_by = 0;
250 41         31 my $rotate_chrome = 0;
251              
252 41         34 my ( @new_phrase, $prev_in, $prev_out );
253 41         30 my $phrase_index = 0;
254 41 50       113 for my $obj ( ref $_[0] eq 'ARRAY' ? @{ $_[0] } : @_ ) {
  0         0  
255 271         170 my $pitch;
256 271 50 33     1000 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         207 $pitch = $obj;
264             } else {
265             # pass through unknowns
266 0         0 push @new_phrase, $obj;
267 0         0 next;
268             }
269              
270 271         171 my $new_pitch;
271 271 100 100     1116 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       335 $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       318 $output_tonic = $input_tonic unless defined $output_tonic;
287              
288 267 100       324 if ( !defined $trans ) {
289 41         73 $trans = $self->get_transpose;
290 41 100       77 if ( !looks_like_number($trans) ) {
291             # Letter note: "transpose to 'A'" instead of "transpose by N"
292 1   50     5 my $transpose_to = $self->pitchnum($trans)
293             // die 'pitchnum failed to parse ' . $self->transpose . "\n";
294 1         64 $trans = $transpose_to - $pitch;
295             }
296              
297 41 100       74 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         11 ( $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         6 $rotate_by *= -1;
307              
308 3 50       6 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       7 if ( $rotate_by != 0 ) {
320 3         13 $output_mode->[$ASC] =
321             $self->atonal->rotate( $rotate_by, $output_mode->[$ASC] );
322 3         81 $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         233 my $input_motion = $ASC;
334 267 100 100     723 $input_motion = $DSC if defined $prev_in and $pitch - $prev_in < 0;
335 267 100       402 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         375 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       498 $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         198 my $idx;
355 267 100       315 if ($steps) {
356 234         151 $steps--;
357 234         283 for my $s ( 0 .. $steps ) {
358 1095         640 $idx = $s % @{ $output_mode->[$output_motion] };
  1095         892  
359 1095 100       1294 $idx = $#{ $output_mode->[$output_motion] } - $idx if $is_dsc;
  707         631  
360 1095         1080 $output_interval += $output_mode->[$output_motion][$idx];
361             }
362             }
363              
364 267         223 my $hooked = 0;
365 267 100       383 if ( $chromatic_offset != 0 ) {
366 85         71 my $step_interval = $output_mode->[$output_motion][$idx];
367 85 50       105 my $step_dir = $step_interval < 0 ? -1 : 1;
368 85         92 $step_interval = abs $step_interval;
369              
370 85 100       101 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         364 $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         26 $hooked = 1;
383             } else {
384 68 100       85 if ( $step_interval == 2 ) {
385             # only one possible chromatic fits
386 63         72 $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       20 if ( $self->get_modal_chrome > 0 ) {
    100          
393 1         2 $output_interval -= $step_dir * $chromatic_offset;
394             } elsif ( $self->get_modal_chrome < 0 ) {
395 1         5 $output_interval += $step_dir * ( $chromatic_offset - $step_interval );
396             } else {
397 3         12 my $fraction = sprintf "%.0f",
398             $step_interval * $chromatic_offset / $last_input_interval;
399 3         7 $output_interval += $step_dir * ( $fraction - $step_interval );
400             }
401             }
402             }
403             }
404              
405 267 100       329 if ( !$hooked ) {
406 250 100       311 $output_interval = int( $output_interval * -1 ) if $is_dsc;
407 250         264 $new_pitch = $output_tonic + $trans + $output_interval;
408             }
409             }
410              
411 271         237 push @new_phrase, $new_pitch;
412 271         209 $prev_in = $pitch;
413 271         202 $prev_out = $new_pitch;
414              
415 271         255 $phrase_index++;
416             }
417 41 100       95 @new_phrase = reverse @new_phrase if $self->get_retrograde;
418              
419 41         279 return @new_phrase;
420             }
421              
422             sub reset_modal_pitches {
423 2     2 1 36 $_[0]->clear_modal_in;
424 2         368 $_[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 4766 my ( $self, $input_pitch, $output_pitch ) = @_;
431              
432 17         20 my $pitch;
433 17 50       64 if ( defined $input_pitch ) {
434 17   50     44 $pitch = $self->pitchnum($input_pitch)
435             // die "pitchnum failed to parse $input_pitch\n";
436 17         110 $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     40 if ( !defined $output_pitch and $self->has_modal_out ) {
441 0         0 $self->clear_modal_out;
442             }
443             }
444 17 50       35 if ( defined $output_pitch ) {
445 17   50     30 $pitch = $self->pitchnum($output_pitch)
446             // die "pitchnum failed to parse $output_pitch\n";
447 17         81 $self->modal_out($pitch);
448             }
449             }
450              
451             sub set_modal_scale_in {
452 5     5 1 69 my $self = shift;
453 5         18 $self->modal_scale_in( $self->scales2intervals(@_) );
454             }
455              
456             sub set_modal_scale_out {
457 17     17 1 60 my $self = shift;
458 17         31 $self->modal_scale_out( $self->scales2intervals(@_) );
459             }
460              
461             sub scales2intervals {
462 22     22 1 28 my ( $self, $asc, $dsc ) = @_;
463 22 0 33     58 if ( !defined $asc and !defined $dsc ) {
464 0         0 die "must define one of asc or dsc or both";
465             }
466              
467 22         16 my @intervals;
468 22         20 my $is_scale = 0;
469 22 50       41 if ( defined $asc ) {
470 22 100       146 if ( ref $asc eq 'ARRAY' ) {
    100          
471             # Assume arbitrary list of intervals as integers if array ref
472 15         26 for my $n (@$asc) {
473 65 50 33     373 die "ascending intervals must be positive integers"
474             unless looks_like_number $n and $n =~ m/^[+]?[0-9]+$/;
475             }
476 15         51 $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       22 die "no Forte Number parsed from ascending '$asc'" unless defined $pset;
482 3         22 $intervals[$ASC] = $self->atonal->pcs2intervals($pset);
483              
484             } else {
485 4 50       23 die "ascending scale '$asc' unknown to Music::Scales"
486             unless is_scale($asc);
487 4         49 my @asc_nums = get_scale_nums($asc);
488 4         83 my @dsc_nums;
489 4 50       17 @dsc_nums = get_scale_nums( $asc, 1 ) unless defined $dsc;
490              
491 4         56 $intervals[$ASC] = [];
492 4         15 for my $i ( 1 .. $#asc_nums ) {
493 24         15 push @{ $intervals[$ASC] }, $asc_nums[$i] - $asc_nums[ $i - 1 ];
  24         44  
494             }
495 4 50       11 if (@dsc_nums) {
496 4         8 $intervals[$DSC] = [];
497 4         10 for my $i ( 1 .. $#dsc_nums ) {
498 24         22 unshift @{ $intervals[$DSC] }, $dsc_nums[ $i - 1 ] - $dsc_nums[$i];
  24         51  
499             }
500             }
501 4         11 $is_scale = 1;
502             }
503             }
504              
505 22 100       83 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       43 $intervals[$DSC] = $intervals[$ASC] unless $is_scale;
510             } else {
511 2 100       32 if ( ref $dsc eq 'ARRAY' ) {
    50          
512 1         1 for my $n (@$dsc) {
513 6 50 33     30 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       6 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       69 if ( !$self->non_octave_scales ) {
539 20         28 for my $ref (@intervals) {
540 40   50     256 my $sum = sum(@$ref) // 0;
541 40 50       62 die "empty interval set\n" if $sum == 0;
542 40 100       700 if ( $sum < $self->DEG_IN_SCALE ) {
    50          
543 25         2437 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         410 return \@intervals;
551             }
552              
553             sub steps {
554 272     272 1 294 my ( $self, $from, $to, $scale ) = @_;
555              
556 272 50       471 die "from pitch must be a number\n" if !looks_like_number $from;
557 272 50       443 die "to pitch must be a number\n" if !looks_like_number $to;
558 272 50 33     855 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         231 my $delta = $to - $from;
563 272 100       307 my $dir = $delta < 0 ? $DSC : $ASC;
564 272         198 $delta = abs $delta;
565              
566 272         184 my $running_total = 0;
567 272         163 my $steps = 0;
568 272         167 my $index = 0;
569 272         407 while ( $running_total < $delta ) {
570 1105         823 $index = $steps++ % @$scale;
571 1105 100       1293 $index = $#{$scale} - $index if $dir == $DSC;
  414         338  
572 1105         1573 $running_total += $scale->[$index];
573             }
574              
575 272         461 return $steps, $running_total - $delta, $dir, $scale->[$index];
576             }
577              
578             1;
579             __END__