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   26108 use 5.010000;
  1         4  
  1         31  
9 1     1   4 use strict;
  1         1  
  1         23  
10 1     1   3 use warnings;
  1         5  
  1         28  
11              
12 1     1   4 use Carp qw/croak/;
  1         1  
  1         124  
13 1     1   6 use List::Util qw/min/;
  1         1  
  1         150  
14 1     1   7 use Scalar::Util qw/reftype/;
  1         14  
  1         100  
15 1     1   977 use Try::Tiny;
  1         1485  
  1         1505  
16              
17             our $VERSION = '0.26';
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   95 my ( $self, $token, $pset_str, $pset2orig ) = @_;
80              
81 50 100       145 if ( !exists $self->{op}->{$token}->{$pset_str} ) {
82 1         25 croak "no set class [$pset_str] for token '$token'";
83             }
84              
85             # apply pitch modifications from the operations table
86 49         48 for my $i ( keys %{ $self->{op}->{$token}->{$pset_str} } ) {
  49         294  
87 67         70 for my $p ( @{ $pset2orig->{$i} } ) {
  67         104  
88 67         225 $p += $self->{op}->{$token}->{$pset_str}->{$i};
89             }
90             }
91              
92             # reformulate the (updated) original pitches into new pitch set
93 49         74 my @new_set;
94 49         84 for my $r ( values %$pset2orig ) {
95 165         260 push @new_set, @$r;
96             }
97              
98 49         102 @new_set = sort { $a <=> $b } @new_set;
  165         189  
99 49         247 return \@new_set;
100             }
101              
102             sub new {
103 1     1 1 549 my ( $class, %param ) = @_;
104 1         8 my $self = { op => \%OPERATIONS, x => \%TRANSFORMATIONS };
105              
106             # should not need to alter, but who knows
107 1   33     11 $self->{_DEG_IN_SCALE} = int( $param{DEG_IN_SCALE} // $DEG_IN_SCALE );
108 1 50       6 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         5 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 64 my $self = shift;
122 50 50       150 my $pset = ref $_[0] eq 'ARRAY' ? $_[0] : [@_];
123              
124 50 50       97 croak 'pitch set must contain something' if !@$pset;
125              
126 50         53 my %origmap;
127 50         65 for my $p (@$pset) {
128 168         180 push @{ $origmap{ $p % $self->{_DEG_IN_SCALE} } }, $p;
  168         549  
129             }
130 50 50       142 if ( keys %origmap == 1 ) {
131 0 0       0 return wantarray ? ( keys %origmap, \%origmap ) : keys %origmap;
132             }
133 50         145 my @nset = sort { $a <=> $b } keys %origmap;
  164         311  
134              
135 50         75 my @equivs;
136 50         102 for my $i ( 0 .. $#nset ) {
137 168         252 for my $j ( 0 .. $#nset ) {
138 576         1316 $equivs[$i][$j] = $nset[ ( $i + $j ) % @nset ];
139             }
140             }
141 50         124 my @order = reverse 1 .. $#nset;
142              
143 50         62 my @normal;
144 50         66 for my $i (@order) {
145 50         72 my $min_span = $self->{_DEG_IN_SCALE};
146 50         46 my @min_span_idx;
147              
148 50         79 for my $eidx ( 0 .. $#equivs ) {
149 168         385 my $span =
150             ( $equivs[$eidx][$i] - $equivs[$eidx][0] ) % $self->{_DEG_IN_SCALE};
151 168 100       341 if ( $span < $min_span ) {
    100          
152 80         73 $min_span = $span;
153 80         160 @min_span_idx = $eidx;
154             } elsif ( $span == $min_span ) {
155 9         16 push @min_span_idx, $eidx;
156             }
157             }
158              
159 50 50       130 if ( @min_span_idx == 1 ) {
160 50         53 @normal = @{ $equivs[ $min_span_idx[0] ] };
  50         107  
161 50         94 last;
162             } else {
163 0         0 @equivs = @equivs[@min_span_idx];
164             }
165             }
166              
167 50 50       89 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       92 if ( $normal[0] != 0 ) {
177 40         64 my $trans = $self->{_DEG_IN_SCALE} - $normal[0];
178 40         38 my %newmap;
179 40         51 for my $i (@normal) {
180 138         146 my $prev = $i;
181 138         181 $i = ( $i + $trans ) % $self->{_DEG_IN_SCALE};
182 138         329 $newmap{$i} = $origmap{$prev};
183             }
184 40         181 %origmap = %newmap;
185             }
186              
187             return
188 50 50       358 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 1117 my ( $self, $tokens, $tasks ) = @_;
197 42   100     165 $tasks //= [];
198 42 50       293 $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         88 for my $t (@$tokens) {
205 61 50       146 if ( exists $self->{x}{$t} ) {
206 61 100 33     168 if ( ref $self->{x}{$t} eq 'CODE' ) {
    50          
207 54         199 push @$tasks, [ $t, $self->{x}{$t} ];
208             } elsif ( !defined reftype $self->{x}{$t}
209             or ref $self->{x}{$t} eq 'ARRAY' ) {
210 7         22 $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         139 return $tasks;
220             }
221              
222 2   100 2 1 682 sub techno { shift; (qw/tonn tz/) x ( 8 * ( shift || 1 ) ) }
  2         36  
223              
224             sub transform {
225 33     33 1 2531 my $self = shift;
226 33         47 my $tokens = shift;
227 33 50       87 croak 'tokens must be defined' unless defined $tokens;
228 33 50       686 my $pset = ref $_[0] eq 'ARRAY' ? $_[0] : [@_];
229 33 50       72 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         36 my $tasks;
234 33 50       59 if ( ref $tokens eq 'ARRAY' ) {
235 0         0 $tasks = $tokens;
236             } else {
237 33     0   209 try { $tasks = $self->taskify_tokens($tokens) } catch { croak $_ };
  33         1393  
  0         0  
238             }
239              
240 33         471 my $new_pset = $pset;
241             try {
242 33     33   1295 for my $task (@$tasks) {
243 50         117 $new_pset =
244             $task->[1]->( $self, $task->[0], $self->normalize($new_pset) );
245             }
246             }
247             catch {
248 1     1   862 croak $_;
249 33         189 };
250 32         531 return $new_pset;
251             }
252              
253             1;
254             __END__