File Coverage

blib/lib/Music/Guidonian.pm
Criterion Covered Total %
statement 118 118 100.0
branch 66 66 100.0
condition 52 52 100.0
subroutine 15 15 100.0
pod 3 3 100.0
total 254 254 100.0


line stmt bran cond sub pod time code
1             # -*- Perl -*-
2             #
3             # Music::Guidonian - a means of melodic phrase generation based on the
4             # "Guidonian Hand" that is credited to Guido of Arezzo
5              
6             package Music::Guidonian;
7             our $VERSION = '0.02';
8              
9 2     2   1321 use 5.24.0;
  2         12  
10 2     2   11 use warnings;
  2         3  
  2         83  
11 2     2   12 use Carp 'croak';
  2         5  
  2         162  
12 2     2   12 use List::Util 'shuffle';
  2         4  
  2         237  
13 2     2   1156 use List::UtilsBy 'nsort_by';
  2         4113  
  2         158  
14 2     2   1423 use Moo;
  2         26627  
  2         15  
15 2     2   6568 use namespace::clean;
  2         25562  
  2         14  
16              
17 2     2   648 use constant { INDEX => 0, CHOICE => 1, FIRST => 0, DONE => -1, DIRTY => -1 };
  2         4  
  2         202  
18              
19 2     2   1191 use parent qw(Exporter);
  2         699  
  2         12  
20             our @EXPORT_OK = qw(intervalize_scale_nums);
21              
22             has key2pitch => ( is => 'rw' );
23              
24             # perldoc Moo
25             sub BUILD {
26 21     21 1 23193 my ( $self, $args ) = @_;
27              
28 21 100 100     110 if ( exists $args->{key2pitch} and exists $args->{key_set} ) {
    100          
    100          
29 1         164 croak "cannot specify both key2pitch and key_set";
30              
31             } elsif ( exists $args->{key2pitch} ) {
32             croak "key2pitch must be a hash reference with keys"
33             unless defined $args->{key2pitch}
34             and ref $args->{key2pitch} eq 'HASH'
35 4 100 100     389 and keys $args->{key2pitch}->%*;
      100        
36              
37             } elsif ( exists $args->{key_set} ) {
38 15         28 my $set = $args->{key_set};
39 15 100 100     444 croak "key_set must be a hash reference with keys"
      100        
40             unless defined $set
41             and ref $set eq 'HASH'
42             and keys $set->%*;
43              
44             croak "intervals must be an array with elements"
45             unless defined $set->{intervals}
46             and ref $set->{intervals} eq 'ARRAY'
47 12 100 100     393 and $set->{intervals}->@*;
      100        
48             croak "keys must be an array with elements"
49             unless defined $set->{keys}
50             and ref $set->{keys} eq 'ARRAY'
51 9 100 100     382 and $set->{keys}->@*;
      100        
52             croak "min must be an integer"
53 6 100 100     278 unless defined $set->{min} and $set->{min} =~ m/^(?a)-?\d+$/;
54             croak "max must be an integer"
55 4 100 100     285 unless defined $set->{max} and $set->{max} =~ m/^(?a)-?\d+$/;
56              
57 2 100       114 croak "min must be less than max" if $set->{min} >= $set->{max};
58              
59 1         2 my $curinterval = 0;
60 1         3 my $curkey = 0;
61 1         2 my %key2pitch;
62 1         3 my $pitch = $set->{min};
63              
64 1         2 while (1) {
65 15         19 push @{ $key2pitch{ $set->{keys}->[$curkey] } }, $pitch;
  15         40  
66 15         23 $pitch += $set->{intervals}->[$curinterval];
67 15 100       28 last if $pitch > $set->{max};
68 14         21 $curinterval = ++$curinterval % $set->{intervals}->@*;
69 14         18 $curkey = ++$curkey % $set->{keys}->@*;
70             }
71 1         7 $self->key2pitch( \%key2pitch );
72              
73             # may want to preserve this for reference or cloning?
74 1         7 delete $args->{key_set};
75              
76             } else {
77 1         208 croak "need key2pitch or key_set";
78             }
79             }
80              
81             ########################################################################
82             #
83             # METHODS
84              
85             sub iterator {
86 19     19 1 9597 my ( $self, $sequence, %param ) = @_;
87 19 100 100     271 croak "sequence is not an array reference"
88             unless defined $sequence and ref $sequence eq 'ARRAY';
89 17 100       124 croak "sequence is too short" if @$sequence < 2;
90              
91 16 100       40 if ( exists $param{renew} ) {
92             croak "renew is not a code reference"
93 3 100 100     185 unless defined $param{renew} and ref $param{renew} eq 'CODE';
94             } else {
95 13         30 $param{renew} = \&_renew;
96             }
97              
98 14         38 my $key2pitch = $self->key2pitch;
99 14 100 100     322 croak "no key2pitch map is set"
      100        
100             unless defined $key2pitch
101             and ref $key2pitch eq 'HASH'
102             and keys %$key2pitch;
103              
104             # the possibilities are either scalars (integer pitch numbers, a
105             # static choice) or an [ INDEX, CHOICE ] array reference where the
106             # CHOICE is an array reference of possible integer pitch numbers
107 11         18 my @possible;
108 11         33 for my $i ( 0 .. $#$sequence ) {
109 23         40 my $s = $sequence->[$i];
110 23 100       136 croak "sequence element is undefined ($i)" unless defined $s;
111 22 100       96 if ( $s =~ m/^(?a)-?\d+$/ ) {
112 8         18 push @possible, $s;
113             } else {
114 14   100     41 my $choices = $key2pitch->{$s} // '';
115 14 100       388 croak "choices are not an array reference for '$s'"
116             unless ref $choices eq 'ARRAY';
117 10         17 my $length = $choices->@*;
118 10 100       105 croak "no choices for '$s' at index $i" if $length == 0;
119 9 100       28 if ( $length == 1 ) {
120 1         3 push @possible, $choices->[0];
121 1         3 next;
122             }
123 8         32 $param{renew}->( $choices, $i, \@possible, $param{stash} );
124 8         63 push @possible, [ FIRST, $choices ]; # INDEX, CHOICE
125             }
126             }
127              
128             # edge case: there is only one iteration due to a lack of choices.
129             # fail so that the iterator is not complicated to handle that
130 5         9 my $refcount = 0;
131 5 100       8 for my $p (@possible) { $refcount++ if ref $p eq 'ARRAY' }
  12         29  
132 5 100       107 croak "no choices in @possible" if $refcount == 0;
133              
134             return sub {
135 8 100   8   2663 return [] unless @possible;
136              
137 7         11 my @phrase;
138 7         14 for my $p (@possible) {
139 19 100       43 if ( ref $p eq 'ARRAY' ) {
140 13         29 push @phrase, 0 + $p->[CHOICE][ $p->[INDEX] ];
141             } else {
142 6         16 push @phrase, 0 + $p;
143             }
144             }
145              
146 7         11 my $dirty = 0;
147 7         23 for my $i ( reverse DONE .. $#possible ) {
148 13 100       36 if ( $i == DONE ) {
    100          
149 1         3 @possible = ();
150 1         3 $dirty = 0;
151 1         2 last;
152             } elsif ( ref $possible[$i] eq 'ARRAY' ) {
153 9 100       21 if ( ++$possible[$i][INDEX] >= $possible[$i][CHOICE]->@* ) {
154 3         6 $possible[$i][INDEX] = DIRTY;
155 3         5 $dirty = 1;
156             } else {
157             # nothing more to update (this time)
158 6         11 last;
159             }
160             }
161             }
162 7 100       18 if ($dirty) {
163 1         4 for my $i ( 0 .. $#possible ) {
164 3 100 100     15 if ( ref $possible[$i] eq 'ARRAY' and $possible[$i][INDEX] == DIRTY ) {
165 1         2 $possible[$i][INDEX] = FIRST;
166 1         7 $param{renew}->( $possible[$i][CHOICE], $i, \@possible, $param{stash} );
167             }
168             }
169             }
170              
171 7         28 return \@phrase;
172 4         71 };
173             }
174              
175             # this has various problems not typical to melodies, such as confining
176             # leaps towards the end of the phrase in early subsequent iterations.
177             # improvements might be to use a non-random starting pitch (e.g. one
178             # suitable to previous material unknown to the current phrase), or to
179             # sometimes shuffle the choices mid-phrase, or to leap when there is a
180             # repeated note?
181             sub _renew {
182 7     7   23 my ( $choices, $index, $possible ) = @_;
183 7 100       16 if ( $index == 0 ) {
184 3         15 $choices = [ shuffle @$choices ];
185             } else {
186 4         10 my $previous = $possible->[ $index - 1 ];
187 4 100       11 my $previous_pitch =
188             ref $previous eq 'ARRAY'
189             ? $previous->[CHOICE][ $previous->[INDEX] ]
190             : $previous;
191 4     8   22 $choices = [ nsort_by { abs( $previous_pitch - $_ ) } $choices->@* ];
  8         59  
192             }
193             }
194              
195             ########################################################################
196             #
197             # FUNCTIONS
198              
199             # convert Music::Scales "get_scale_nums" to the interval for each step,
200             # making various assumptions (or lack of sanity tests) along the way
201             # (pretty sure I've written this same code elsewhere...)
202             sub intervalize_scale_nums {
203 2     2 1 1814 my ( $scale, $max_interval ) = @_;
204 2   100     9 $max_interval ||= 12; # assume Western 12-tone system
205 2         4 my @intervals;
206 2         3 my $previous = 0;
207 2         5 for my $s (@$scale) {
208 14 100       28 next if $s == 0;
209 12         47 push @intervals, $s - $previous;
210 12         17 $previous = $s;
211             }
212 2         5 push @intervals, $max_interval - $previous;
213 2         8 return \@intervals;
214             }
215              
216             1;
217             __END__