File Coverage

blib/lib/MIDI/Ngram.pm
Criterion Covered Total %
statement 251 283 88.6
branch 56 84 66.6
condition 15 33 45.4
subroutine 23 26 88.4
pod 6 6 100.0
total 351 432 81.2


line stmt bran cond sub pod time code
1             package MIDI::Ngram;
2             our $AUTHORITY = 'cpan:GENE';
3              
4             # ABSTRACT: Find the top repeated note phrases of MIDI files
5              
6             our $VERSION = '0.1808';
7              
8 1     1   646 use Carp qw(croak);
  1         2  
  1         45  
9 1     1   382 use Lingua::EN::Ngram ();
  1         1144  
  1         34  
10 1     1   6 use List::Util qw( shuffle uniq );
  1         1  
  1         87  
11 1     1   355 use List::Util::WeightedChoice qw( choose_weighted );
  1         10409  
  1         53  
12 1     1   362 use MIDI::Util qw(setup_score set_chan_patch);
  1         21505  
  1         53  
13 1     1   346 use Music::Note ();
  1         1351  
  1         21  
14              
15 1     1   446 use Moo;
  1         6908  
  1         5  
16 1     1   1569 use strictures 2;
  1         1347  
  1         36  
17 1     1   550 use namespace::clean;
  1         6912  
  1         8  
18              
19              
20             has in_file => (
21             is => 'ro',
22             isa => \&_is_list,
23             required => 1,
24             );
25              
26              
27             has ngram_size => (
28             is => 'ro',
29             isa => \&_is_integer,
30             default => sub { 2 },
31             );
32              
33              
34             has min_phrases => (
35             is => 'ro',
36             isa => \&_is_integer,
37             default => sub { 2 },
38             );
39              
40              
41             has max_phrases => (
42             is => 'ro',
43             isa => \&_is_integer0,
44             default => sub { 0 },
45             );
46              
47              
48             has bpm => (
49             is => 'ro',
50             isa => \&_is_integer,
51             default => sub { 100 },
52             );
53              
54              
55             has durations => (
56             is => 'ro',
57             isa => \&_is_list,
58             default => sub { [] },
59             );
60              
61              
62             has patches => (
63             is => 'ro',
64             isa => \&_is_list,
65             default => sub { [ 0 .. 127 ] },
66             );
67              
68              
69             has random_patch => (
70             is => 'ro',
71             isa => \&_is_boolean,
72             default => sub { 0 },
73             );
74              
75              
76             has out_file => (
77             is => 'ro',
78             default => sub { 'midi-ngram.mid' },
79             );
80              
81              
82             has pause_duration => (
83             is => 'ro',
84             isa => sub { croak 'Invalid duration' unless $_[0] eq '' || $_[0] =~ /^[a-z]+$/ },
85             default => sub { '' },
86             );
87              
88              
89             has analyze => (
90             is => 'ro',
91             isa => \&_is_list,
92             );
93              
94              
95             has loop => (
96             is => 'ro',
97             isa => \&_is_integer,
98             default => sub { 10 },
99             );
100              
101              
102             has weight => (
103             is => 'ro',
104             isa => \&_is_boolean,
105             default => sub { 0 },
106             );
107              
108              
109             has shuffle_phrases => (
110             is => 'ro',
111             isa => \&_is_boolean,
112             default => sub { 0 },
113             );
114              
115              
116             has one_channel => (
117             is => 'ro',
118             isa => \&_is_boolean,
119             default => sub { 0 },
120             );
121              
122              
123             has score => (
124             is => 'rw',
125             init_arg => undef,
126             lazy => 1,
127             );
128              
129             has _opus_ticks => (
130             is => 'rw',
131             );
132              
133              
134             has dura_notes => (
135             is => 'ro',
136             init_arg => undef,
137             default => sub { {} },
138             );
139              
140              
141             has notes => (
142             is => 'ro',
143             init_arg => undef,
144             default => sub { {} },
145             );
146              
147             has _event_list => (
148             is => 'ro',
149             init_arg => undef,
150             lazy => 1,
151             builder => 1,
152             );
153              
154             sub _build__event_list {
155 1     1   10 my ($self) = @_;
156              
157 1         2 my %events;
158              
159 1         2 for my $file ( @{ $self->in_file } ) {
  1         4  
160 1         10 my $opus = MIDI::Opus->new({ from_file => $file });
161              
162             # XXX Assume that all files have the same MIDI ppqn
163 1 50       4983 $self->_opus_ticks($opus->ticks)
164             unless $self->_opus_ticks;
165              
166 1         11 for my $t ( $opus->tracks ) {
167 3         18 my $score_r = MIDI::Score::events_r_to_score_r( $t->events_r );
168             #MIDI::Score::dump_score($score_r);
169              
170             # Collect the note events
171 3         2608 for my $event (@$score_r) {
172             # ['note', , , , , ]
173 102 100       224 if ($event->[0] eq 'note') {
174 69         86 push @{ $events{ $event->[3] }{ $event->[1] } },
  69         349  
175             { note => $event->[4], dura => $event->[2] };
176             }
177             }
178             }
179             }
180              
181 1         12 return \%events;
182             }
183              
184              
185             has dura => (
186             is => 'ro',
187             init_arg => undef,
188             default => sub { {} },
189             );
190              
191             has _dura_list => (
192             is => 'ro',
193             init_arg => undef,
194             default => sub { {} },
195             );
196              
197              
198             has dura_net => (
199             is => 'ro',
200             init_arg => undef,
201             lazy => 1,
202             builder => 1,
203             );
204              
205             sub _build_dura_net {
206 1     1   4294 my ($self) = @_;
207              
208 1         2 my %net;
209              
210 1         2 for my $channel (sort { $a <=> $b } keys %{ $self->_event_list }) {
  1         12  
  1         17  
211 2         3 my @group;
212             my $last;
213              
214 2         4 for my $start (sort { $a <=> $b } keys %{ $self->_event_list->{$channel} }) {
  267         278  
  2         28  
215 69         74 my $duras = join ',', map { $_->{dura} } @{ $self->_event_list->{$channel}{$start} };
  69         499  
  69         1018  
216              
217 69 100       165 if (@group == $self->ngram_size) {
218 33         48 my $group = join ' ', @group;
219 33 100       70 $net{$channel}{ $last . '-' . $group }++
220             if $last;
221 33         35 $last = $group;
222 33         51 @group = ();
223             }
224 69         114 push @group, $self->dura_convert($duras);
225             }
226             }
227              
228 1         4 for my $channel (keys %net) {
229 2         3 for my $node (keys %{ $net{$channel} }) {
  2         6  
230             delete $net{$channel}{$node}
231 9 100       20 if $net{$channel}{$node} < $self->min_phrases;
232             }
233             }
234              
235 1         9 return \%net;
236             }
237              
238              
239             has note_net => (
240             is => 'ro',
241             init_arg => undef,
242             lazy => 1,
243             builder => 1,
244             );
245              
246             sub _build_note_net {
247 1     1   9 my ($self) = @_;
248              
249 1         2 my %net;
250              
251 1         3 for my $channel (sort { $a <=> $b } keys %{ $self->_event_list }) {
  1         11  
  1         13  
252 2         5 my @group;
253             my $last;
254              
255 2         3 for my $start (sort { $a <=> $b } keys %{ $self->_event_list->{$channel} }) {
  267         274  
  2         31  
256 69         89 my $notes = join ',', map { $_->{note} } @{ $self->_event_list->{$channel}{$start} };
  69         491  
  69         975  
257              
258 69 100       153 if (@group == $self->ngram_size) {
259 33         48 my $group = join ' ', @group;
260 33 100       76 $net{$channel}{ $last . '-' . $group }++
261             if $last;
262 33         38 $last = $group;
263 33         50 @group = ();
264             }
265              
266 69         113 push @group, $self->note_convert($notes);
267             }
268             }
269              
270 1         5 for my $channel (keys %net) {
271 2         3 for my $node (keys %{ $net{$channel} }) {
  2         6  
272             delete $net{$channel}{$node}
273 20 100       49 if $net{$channel}{$node} < $self->min_phrases;
274             }
275             }
276              
277 1         9 return \%net;
278             }
279              
280              
281             has dura_note_net => (
282             is => 'ro',
283             init_arg => undef,
284             lazy => 1,
285             builder => 1,
286             );
287              
288             sub _build_dura_note_net {
289 1     1   11 my ($self) = @_;
290              
291 1         2 my %net;
292              
293 1         3 for my $channel (sort { $a <=> $b } keys %{ $self->_event_list }) {
  1         11  
  1         14  
294 2         3 my @group;
295             my $last;
296              
297 2         2 for my $start (sort { $a <=> $b } keys %{ $self->_event_list->{$channel} }) {
  267         288  
  2         39  
298 69         80 my $nodes = join ',', map { "|$_->{dura}*$_->{note}|" } @{ $self->_event_list->{$channel}{$start} };
  69         558  
  69         1121  
299              
300 69 100       186 if (@group == $self->ngram_size) {
301 33         47 my $group = join ' ', @group;
302 33 100       75 $net{$channel}{ $last . '-' . $group }++
303             if $last;
304 33         39 $last = $group;
305 33         53 @group = ();
306             }
307 69         101 push @group, $self->dura_note_convert($nodes);
308             }
309             }
310              
311 1         4 for my $channel (keys %net) {
312 2         3 for my $node (keys %{ $net{$channel} }) {
  2         6  
313             delete $net{$channel}{$node}
314 22 100       39 if $net{$channel}{$node} < $self->min_phrases;
315             }
316             }
317              
318 1         8 return \%net;
319             }
320              
321              
322             sub process {
323 1     1 1 1014 my ($self) = @_;
324              
325 1         2 for my $channel (sort { $a <=> $b } keys %{ $self->_event_list }) {
  1         6  
  1         25  
326             # Skip if this is not a channel to analyze
327 0         0 next if $self->analyze && keys @{ $self->analyze }
328 2 0 33     12 && !grep { $_ == $channel } @{ $self->analyze };
  0   33     0  
  0         0  
329              
330 2         4 my $dura_note_text = '';
331 2         4 my $dura_text = '';
332 2         3 my $note_text = '';
333              
334 2         4 for my $start (sort { $a <=> $b } keys %{ $self->_event_list->{$channel} }) {
  267         321  
  2         60  
335             # CSV durations and notes
336 69         111 my $dura_notes = join ',', map { "|$_->{dura}*$_->{note}|" } @{ $self->_event_list->{$channel}{$start} };
  69         555  
  69         977  
337 69         97 my $duras = join ',', map { $_->{dura} } @{ $self->_event_list->{$channel}{$start} };
  69         435  
  69         918  
338 69         89 my $notes = join ',', map { $_->{note} } @{ $self->_event_list->{$channel}{$start} };
  69         424  
  69         872  
339              
340             # Transliterate MIDI note numbers to alpha-code
341 69         105 ( my $str = $duras ) =~ tr/0-9,/a-k/;
342 69         85 $dura_text .= "$str ";
343 69         101 ( $str = $notes ) =~ tr/0-9,/a-k/;
344 69         81 $note_text .= "$str ";
345 69         95 ( $str = $dura_notes ) =~ tr/0-9,*|/a-m/;
346 69         108 $dura_note_text .= "$str ";
347             }
348              
349             # Parse the note text into ngrams
350 2         17 my $dura_note_ngram = Lingua::EN::Ngram->new( text => $dura_note_text );
351 2         70 my $dura_note_phrase = $dura_note_ngram->ngram( $self->ngram_size );
352 2         704 my $dura_ngram = Lingua::EN::Ngram->new( text => $dura_text );
353 2         32 my $dura_phrase = $dura_ngram->ngram( $self->ngram_size );
354 2         890 my $note_ngram = Lingua::EN::Ngram->new( text => $note_text );
355 2         30 my $note_phrase = $note_ngram->ngram( $self->ngram_size );
356              
357             # Counter for the ngrams seen
358 2         622 my $j = 0;
359              
360             # Display the ngrams in order of their repetition amount
361 2 50       17 for my $p ( sort { $dura_phrase->{$b} <=> $dura_phrase->{$a} || $a cmp $b } keys %$dura_phrase ) {
  12         26  
362             # Skip single occurance phrases if requested
363 9 100       31 next if $dura_phrase->{$p} < $self->min_phrases;
364              
365 7         8 $j++;
366              
367             # End if a max is set and we are past the maximum
368 7 50 33     17 last if $self->max_phrases > 0 && $j > $self->max_phrases;
369              
370             # Transliterate our letter code back to MIDI note numbers
371 7         15 ( my $num = $p ) =~ tr/a-k/0-9,/;
372              
373             # Convert MIDI numbers to named durations.
374 7         14 my $text = $self->dura_convert($num);
375              
376             # Save the number of times the phrase is repeated
377 7         31 $self->dura->{$channel}{$text} += $dura_phrase->{$p};
378             }
379              
380 2 50       4 unless (@{ $self->durations }) {
  2         10  
381             # Build the durations set
382 2         3 for my $channel (keys %{ $self->dura }) {
  2         6  
383 3         5 for my $duras (keys %{ $self->dura->{$channel} }) {
  3         9  
384             # A dura string is a space separated, CSV
385 10         16 my @duras = split / /, $duras;
386 10         10 push @{ $self->_dura_list->{$channel} }, map { split /,/, $_ } @duras;
  10         23  
  20         37  
387             }
388 3         5 $self->_dura_list->{$channel} = [ uniq @{ $self->_dura_list->{$channel} } ];
  3         39  
389             }
390             }
391              
392             # Reset counter for the ngrams seen
393 2         5 $j = 0;
394              
395             # Display the ngrams in order of their repetition amount
396 2 50       9 for my $p ( sort { $note_phrase->{$b} <=> $note_phrase->{$a} || $a cmp $b } keys %$note_phrase ) {
  72         159  
397             # Skip single occurance phrases if requested
398 27 100       54 next if $note_phrase->{$p} < $self->min_phrases;
399              
400 23         43 $j++;
401              
402             # End if a max is set and we are past the maximum
403 23 50 33     50 last if $self->max_phrases > 0 && $j > $self->max_phrases;
404              
405             # Transliterate our letter code back to MIDI note numbers
406 23         76 ( my $num = $p ) =~ tr/a-k/0-9,/;
407              
408             # Convert MIDI numbers to named notes.
409 23         43 my $text = $self->note_convert($num);
410              
411             # Save the number of times the phrase is repeated
412 23         65 $self->notes->{$channel}{$text} += $note_phrase->{$p};
413             }
414              
415             # Reset counter for the ngrams seen
416 2         5 $j = 0;
417              
418             # Display the ngrams in order of their repetition amount
419 2 50       14 for my $p ( sort { $dura_note_phrase->{$b} <=> $dura_note_phrase->{$a} || $a cmp $b } keys %$dura_note_phrase ) {
  108         144  
420             # Skip single occurance phrases if requested
421 35 100       109 next if $dura_note_phrase->{$p} < $self->min_phrases;
422              
423 24         27 $j++;
424              
425             # End if a max is set and we are past the maximum
426 24 50 33     45 last if $self->max_phrases > 0 && $j > $self->max_phrases;
427              
428             # Transliterate our letter code back to MIDI note numbers
429 24         43 ( my $num = $p ) =~ tr/a-m/0-9,*|/;
430              
431             # Convert MIDI numbers to named notes.
432 24         37 my $text = $self->dura_note_convert($num);
433              
434             # Save the number of times the phrase is repeated
435 24         74 $self->dura_notes->{$channel}{$text} += $dura_note_phrase->{$p};
436             }
437             }
438             }
439              
440              
441             sub populate {
442 1     1 1 2562 my ($self) = @_;
443              
444 1         9 my $score = setup_score( bpm => $self->bpm );
445 1         441 $self->score($score);
446              
447 1         2 my @phrases;
448             my $playback;
449              
450 1 50       5 if ( $self->weight ) {
451 0         0 $playback = "Weighted playback:\n\tLoop\tChan\tPhrase\n";
452              
453 0         0 for my $channel ( sort { $a <=> $b } keys %{ $self->notes } ) {
  0         0  
  0         0  
454 0 0       0 my $track_chan = $self->one_channel ? 0 : $channel;
455              
456             # Create a function that adds notes to the score
457             my $func = sub {
458 0 0   0   0 my $patch = $self->random_patch ? $self->_random_patch() : 0;
459              
460 0         0 set_chan_patch( $self->score, $track_chan, $patch );
461              
462 0         0 for my $n ( 1 .. $self->loop ) {
463             my $choice = choose_weighted(
464 0         0 [ keys %{ $self->notes->{$channel} } ],
465 0         0 [ values %{ $self->notes->{$channel} } ]
  0         0  
466             );
467              
468 0         0 $playback .= "\t$n\t$track_chan\t$choice\n";
469              
470             # Add each chosen note to the score
471 0         0 for my $note ( split /\s+/, $choice ) {
472 0         0 my @note = split /,/, $note;
473 0         0 my $duration = @{ $self->durations }
474 0         0 ? $self->durations->[ int rand @{ $self->durations } ]
475 0 0       0 : $self->_dura_list->{$channel}[ int rand @{ $self->_dura_list->{$channel} } ];
  0         0  
476 0         0 $self->score->n( $duration, @note );
477             }
478              
479 0 0       0 $self->score->r( $self->pause_duration )
480             if $self->pause_duration;
481             }
482 0         0 };
483              
484 0         0 push @phrases, $func;
485             }
486             }
487             else {
488 1 50       5 my $type = $self->shuffle_phrases ? 'Shuffled' : 'Ordered';
489 1         3 $playback = "$type playback:\n\tN\tChan\tPhrase\n";
490              
491 1         2 my $n = 0;
492              
493 1         2 for my $channel ( keys %{ $self->notes } ) {
  1         5  
494 2 50       9 my $track_chan = $self->one_channel ? 0 : $channel;
495              
496 2         14 my $notes = $self->notes->{$channel};
497              
498             # Shuffle the phrases if requested
499             my @track_notes = $self->shuffle_phrases
500             ? shuffle keys %$notes
501 2 50       17 : sort { $notes->{$b} <=> $notes->{$a} || $a cmp $b } keys %$notes;
  61 50       90  
502              
503             # Temporary list of all the phrase notes
504 2         4 my @all;
505              
506             # Add the notes to a bucket
507 2         5 for my $phrase ( @track_notes ) {
508 23         24 $n++;
509              
510 23         34 $playback .= "\t$n\t$track_chan\t$phrase\n";
511              
512 23         47 my @phrase = split /\s/, $phrase;
513 23         37 push @all, @phrase;
514 23 50       47 push @all, 'r'
515             if $self->pause_duration;
516             }
517              
518             # Create a function that adds our bucket of notes to the score
519             my $func = sub {
520 2 50   2   97 my $patch = $self->random_patch ? $self->_random_patch() : 0;
521              
522 2         8 set_chan_patch( $self->score, $track_chan, $patch);
523              
524 2         87 for my $note ( @all ) {
525 46 50       2515 if ( $note eq 'r' ) {
526 0         0 $self->score->r( $self->pause_duration );
527             }
528             else {
529 46         72 my @note = split /,/, $note;
530 46         119 my $duration = @{ $self->durations }
531 0         0 ? $self->durations->[ int rand @{ $self->durations } ]
532 46 50       51 : $self->_dura_list->{$channel}[ int rand @{ $self->_dura_list->{$channel} } ];
  46         113  
533 46         99 $self->score->n( $duration, @note );
534             }
535             }
536 2         15 };
537              
538 2         8 push @phrases, $func;
539             }
540             }
541              
542 1         20 $self->score->synch(@phrases);
543              
544 1         72 return $playback;
545             }
546              
547              
548             sub write {
549 0     0 1 0 my ($self) = @_;
550 0         0 $self->score->write_score( $self->out_file );
551             }
552              
553              
554             sub dura_convert {
555 197     197 1 3221 my ($self, $string) = @_;
556              
557 197         206 my @text;
558              
559 197         202 my $match = 0;
560              
561 197         394 for my $n ( split /\s+/, $string ) {
562 204         202 my @csv;
563              
564 204         291 for my $v (split /,/, $n) {
565 206         449 my $dura = $v / $self->_opus_ticks;
566              
567 206         585 for my $d (keys %MIDI::Simple::Length) {
568 1730 100       5146 if (sprintf('%.4f', $MIDI::Simple::Length{$d}) eq sprintf('%.4f', $dura)) {
569 206         214 $match++;
570 206         215 $dura = $d;
571 206         235 last;
572             }
573             }
574              
575 206 50       456 push @csv, $match ? $dura : 'd' . $v;
576 206         307 $match = 0;
577             }
578              
579 204         437 push @text, join ',', @csv;
580             }
581              
582 197         472 return join ' ', @text;
583             }
584              
585              
586             sub note_convert {
587 213     213 1 370 my ($self, $string) = @_;
588              
589 213         243 my @text;
590              
591 213         468 for my $n ( split /\s+/, $string ) {
592 238         254 my @csv;
593              
594 238         323 for my $v (split /,/, $n) {
595 240         518 my $note = Music::Note->new( $v, 'midinum' );
596 240         4979 push @csv, $note->format('midi');
597             }
598              
599 238         4214 push @text, join ',', @csv;
600             }
601              
602 213         414 return join ' ', @text;
603             }
604              
605              
606             sub dura_note_convert {
607 95     95 1 155 my ($self, $string) = @_;
608              
609 95         107 my @text;
610              
611 95         187 for my $frag ( split /\|/, $string ) {
612 236         257 my $processed = $frag;
613              
614 236 100       575 if ($frag =~ /^([\w,]+)\*([\w,]+)$/) {
615 119         201 $processed = $self->dura_convert($1)
616             . '*'
617             . $self->note_convert($2);
618             }
619              
620 236         349 push @text, $processed;
621             }
622              
623 95         199 return join '', @text;
624             }
625              
626             sub _random_patch {
627 0     0   0 my ($self) = @_;
628 0         0 return $self->patches->[ int rand @{ $self->patches } ];
  0         0  
629             }
630              
631             sub _is_integer0 {
632 11 100 66 11   244 croak 'Not greater than or equal to zero'
633             unless defined $_[0] && $_[0] =~ /^\d+$/;
634             }
635              
636             sub _is_integer {
637 46 100 33 46   1068 croak 'Invalid integer'
      66        
638             unless defined $_[0] && $_[0] =~ /^\d+$/ && $_[0] > 0;
639             }
640              
641             sub _is_list {
642 35 100   35   720 croak 'Invalid list'
643             unless ref $_[0] eq 'ARRAY';
644             }
645              
646             sub _is_boolean {
647 20 100 66 20   469 croak 'Invalid Boolean'
      33        
      66        
648             unless defined $_[0] && $_[0] =~ /^\d$/ && ( $_[0] == 1 || $_[0] == 0 );
649             }
650              
651             1;
652              
653             __END__