File Coverage

blib/lib/Music/Canon.pm
Criterion Covered Total %
statement 198 221 89.5
branch 99 134 73.8
condition 19 41 46.3
subroutine 19 19 100.0
pod 11 12 91.6
total 346 427 81.0


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