File Coverage

blib/lib/Music/NeoRiemannianTonnetz.pm
Criterion Covered Total %
statement 106 115 92.1
branch 24 40 60.0
condition 6 10 60.0
subroutine 15 16 93.7
pod 5 5 100.0
total 156 186 83.8


line stmt bran cond sub pod time code
1             # -*- Perl -*-
2             #
3             # Performs Neo-Riemann operations on set classes.
4             # https://en.wikipedia.org/wiki/Neo-Riemannian_theory
5              
6             package Music::NeoRiemannianTonnetz;
7              
8 1     1   29133 use 5.010000;
  1         4  
  1         57  
9 1     1   7 use strict;
  1         2  
  1         41  
10 1     1   7 use warnings;
  1         7  
  1         56  
11              
12 1     1   7 use Carp qw/croak/;
  1         3  
  1         97  
13 1     1   7 use List::Util qw/min/;
  1         2  
  1         149  
14 1     1   7 use Scalar::Util qw/reftype/;
  1         2  
  1         115  
15 1     1   723 use Try::Tiny;
  1         1513  
  1         1646  
16              
17             our $VERSION = '0.27';
18              
19             my $DEG_IN_SCALE = 12;
20              
21             # For the transform table. "SEE ALSO" section in docs has links for
22             # [refs]. These are expanded as a simple grammar, until a code reference
23             # is reached; the code reference then looks up what needs to be done via
24             # the operations table.
25             my %TRANSFORMATIONS = (
26             # 3-11 operations
27             P => \&_apply_operation, # Parallel [WP]
28             R => \&_apply_operation, # Relative [WP]
29             L => \&_apply_operation, # Leittonwechsel [WP]
30             N => 'RLP', # Nebenverwandt [WP]
31             S => 'LPR', # Slide [WP]
32             H => 'LPL', # [WP]
33              
34             # 4-27 operations [Childs 1998]
35             S23 => \&_apply_operation,
36             S32 => \&_apply_operation,
37             S34 => \&_apply_operation,
38             S43 => \&_apply_operation,
39             S56 => \&_apply_operation,
40             S65 => \&_apply_operation,
41             C32 => \&_apply_operation,
42             C34 => \&_apply_operation,
43             C65 => \&_apply_operation,
44             );
45              
46             # The important bits (these munge set classes to a different form of the
47             # same parent prime form of a set class, e.g. toggling 0,3,7 to 0,4,7).
48             # The operation names come from the literature, as well as the magic
49             # numbers required to change the set classes correctly.
50             my %OPERATIONS = (
51             L => { '0,3,7' => { 7 => 1 }, '0,4,7' => { 0 => -1 } },
52             P => { '0,3,7' => { 3 => 1 }, '0,4,7' => { 4 => -1 } },
53             R => { '0,3,7' => { 0 => -2 }, '0,4,7' => { 7 => 2 } },
54             S23 =>
55             { '0,3,6,8' => { 0 => -1, 3 => -1 }, '0,2,5,8' => { 5 => 1, 8 => 1 } },
56             S32 =>
57             { '0,3,6,8' => { 6 => 1, 8 => 1 }, '0,2,5,8' => { 0 => -1, 2 => -1 } },
58             S34 =>
59             { '0,3,6,8' => { 0 => 1, 8 => 1 }, '0,2,5,8' => { 0 => -1, 8 => -1 } },
60             S43 =>
61             { '0,3,6,8' => { 3 => -1, 6 => -1 }, '0,2,5,8' => { 2 => 1, 5 => 1 } },
62             S56 =>
63             { '0,3,6,8' => { 0 => -1, 6 => -1 }, '0,2,5,8' => { 2 => 1, 8 => 1 } },
64             S65 =>
65             { '0,3,6,8' => { 3 => 1, 8 => 1 }, '0,2,5,8' => { 0 => -1, 5 => -1 } },
66             C32 =>
67             { '0,3,6,8' => { 6 => -1, 8 => 1 }, '0,2,5,8' => { 0 => -1, 2 => 1 } },
68             C34 =>
69             { '0,3,6,8' => { 0 => -1, 8 => 1 }, '0,2,5,8' => { 0 => -1, 8 => 1 } },
70             C65 =>
71             { '0,3,6,8' => { 3 => -1, 8 => 1 }, '0,2,5,8' => { 0 => -1, 5 => 1 } },
72             );
73              
74             ########################################################################
75             #
76             # SUBROUTINES
77              
78             sub _apply_operation {
79 50     50   67 my ( $self, $token, $pset_str, $pset2orig ) = @_;
80              
81 50 100       127 if ( !exists $self->{op}->{$token}->{$pset_str} ) {
82 1         27 croak "no set class [$pset_str] for token '$token'";
83             }
84              
85             # apply pitch modifications from the operations table
86 49         39 for my $i ( keys %{ $self->{op}->{$token}->{$pset_str} } ) {
  49         113  
87 67         47 for my $p ( @{ $pset2orig->{$i} } ) {
  67         77  
88 67         151 $p += $self->{op}->{$token}->{$pset_str}->{$i};
89             }
90             }
91              
92             # reformulate the (updated) original pitches into new pitch set
93 49         44 my @new_set;
94 49         64 for my $r ( values %$pset2orig ) {
95 165         166 push @new_set, @$r;
96             }
97              
98 49         101 @new_set = sort { $a <=> $b } @new_set;
  170         141  
99 49         198 return \@new_set;
100             }
101              
102             sub new {
103 1     1 1 257 my ( $class, %param ) = @_;
104 1         6 my $self = { op => \%OPERATIONS, x => \%TRANSFORMATIONS };
105              
106             # should not need to alter, but who knows
107 1   33     8 $self->{_DEG_IN_SCALE} = int( $param{DEG_IN_SCALE} // $DEG_IN_SCALE );
108 1 50       3 if ( $self->{_DEG_IN_SCALE} < 2 ) {
109 0         0 croak 'degrees in scale must be greater than one';
110             }
111              
112 1         3 bless $self, $class;
113              
114 1         2 return $self;
115             }
116              
117             # Based on normal_form of Music::AtonalUtil but always transposes to
118             # zero (cannot use prime_form, as that goes one step too far and
119             # conflates [0,4,7] with [0,3,7] which here must be distinct).
120             sub normalize {
121 50     50 1 44 my $self = shift;
122 50 50       91 my $pset = ref $_[0] eq 'ARRAY' ? $_[0] : [@_];
123              
124 50 50       203 croak 'pitch set must contain something' if !@$pset;
125              
126 50         42 my %origmap;
127 50         52 for my $p (@$pset) {
128 168         106 push @{ $origmap{ $p % $self->{_DEG_IN_SCALE} } }, $p;
  168         366  
129             }
130 50 50       115 if ( keys %origmap == 1 ) {
131 0 0       0 return wantarray ? ( keys %origmap, \%origmap ) : keys %origmap;
132             }
133 50         125 my @nset = sort { $a <=> $b } keys %origmap;
  162         232  
134              
135 50         52 my @equivs;
136 50         90 for my $i ( 0 .. $#nset ) {
137 168         162 for my $j ( 0 .. $#nset ) {
138 576         976 $equivs[$i][$j] = $nset[ ( $i + $j ) % @nset ];
139             }
140             }
141 50         95 my @order = reverse 1 .. $#nset;
142              
143 50         42 my @normal;
144 50         52 for my $i (@order) {
145 50         48 my $min_span = $self->{_DEG_IN_SCALE};
146 50         38 my @min_span_idx;
147              
148 50         56 for my $eidx ( 0 .. $#equivs ) {
149 168         214 my $span =
150             ( $equivs[$eidx][$i] - $equivs[$eidx][0] ) % $self->{_DEG_IN_SCALE};
151 168 100       326 if ( $span < $min_span ) {
    100          
152 80         60 $min_span = $span;
153 80         315 @min_span_idx = $eidx;
154             } elsif ( $span == $min_span ) {
155 9         13 push @min_span_idx, $eidx;
156             }
157             }
158              
159 50 50       79 if ( @min_span_idx == 1 ) {
160 50         32 @normal = @{ $equivs[ $min_span_idx[0] ] };
  50         81  
161 50         65 last;
162             } else {
163 0         0 @equivs = @equivs[@min_span_idx];
164             }
165             }
166              
167 50 50       81 if ( !@normal ) {
168             # nothing unique, pick lowest starting pitch, which is first index
169             # by virtue of the numeric sort performed above.
170 0         0 @normal = @{ $equivs[0] };
  0         0  
171             }
172              
173             # but must map (and anything else not ) so b is 0,
174             # dis 4, etc. and also update the original pitch mapping - this is
175             # the major addition to the otherwise stock normal_form code.
176 50 100       78 if ( $normal[0] != 0 ) {
177 40         45 my $trans = $self->{_DEG_IN_SCALE} - $normal[0];
178 40         29 my %newmap;
179 40         39 for my $i (@normal) {
180 138         101 my $prev = $i;
181 138         130 $i = ( $i + $trans ) % $self->{_DEG_IN_SCALE};
182 138         205 $newmap{$i} = $origmap{$prev};
183             }
184 40         151 %origmap = %newmap;
185             }
186              
187             return
188 50 50       291 wantarray ? ( join( ',', @normal ), \%origmap ) : join( ',', @normal );
189             }
190              
191             # Turns string of tokens (e.g. 'RLP') into a list of tasks (CODE refs,
192             # or more strings, which are recursed on until CODE refs or error).
193             # Returns array reference of such tasks. Called by transform() if user
194             # has not already done this and passes transform() a string of tokens.
195             sub taskify_tokens {
196 42     42 1 964 my ( $self, $tokens, $tasks ) = @_;
197 42   100     137 $tasks //= [];
198 42 50       401 $tokens = [ $tokens =~ m/([A-Z][a-z0-9]*)/g ] if !defined reftype $tokens;
199              
200             # XXX optimize input? - runs of P can be reduced, as those just toggle
201             # the third - even number of P a no-op, odd number of P can be
202             # replaced with 'P'. Other optimizations are likely possible.
203              
204 42         69 for my $t (@$tokens) {
205 61 50       112 if ( exists $self->{x}{$t} ) {
206 61 100 33     131 if ( ref $self->{x}{$t} eq 'CODE' ) {
    50          
207 54         131 push @$tasks, [ $t, $self->{x}{$t} ];
208             } elsif ( !defined reftype $self->{x}{$t}
209             or ref $self->{x}{$t} eq 'ARRAY' ) {
210 7         18 $self->taskify_tokens( $self->{x}{$t}, $tasks );
211             } else {
212 0         0 croak 'unknown token in transformation table';
213             }
214             } else {
215 0         0 croak "unimplemented transformation token '$t'";
216             }
217             }
218              
219 42         101 return $tasks;
220             }
221              
222 2   100 2 1 849 sub techno { shift; (qw/tonn tz/) x ( 8 * ( shift || 1 ) ) }
  2         34  
223              
224             sub transform {
225 33     33 1 3701 my $self = shift;
226 33         34 my $tokens = shift;
227 33 50       903 croak 'tokens must be defined' unless defined $tokens;
228 33 50       76 my $pset = ref $_[0] eq 'ARRAY' ? $_[0] : [@_];
229 33 50       52 croak 'pitch set must contain something' if !@$pset;
230              
231             # Assume list of tasks (code refs to call) if array ref, otherwise try
232             # to generate such a list.
233 33         23 my $tasks;
234 33 50       49 if ( ref $tokens eq 'ARRAY' ) {
235 0         0 $tasks = $tokens;
236             } else {
237 33     0   194 try { $tasks = $self->taskify_tokens($tokens) } catch { croak $_ };
  33         1516  
  0         0  
238             }
239              
240 33         334 my $new_pset = $pset;
241             try {
242 33     33   974 for my $task (@$tasks) {
243 50         99 $new_pset =
244             $task->[1]->( $self, $task->[0], $self->normalize($new_pset) );
245             }
246             }
247             catch {
248 1     1   618 croak $_;
249 33         157 };
250 32         534 return $new_pset;
251             }
252              
253             1;
254             __END__