#!/usr/bin/perl my $Version = '4.0 for Perl5'; my $VersionDate = '20100306'; # 20100306 4.0 bug fixed in pan effect # 20100203 3.9 pitch as synonym for key effect # 20091128 3.8 fetches URLs as input-filenames # 20091127 3.7 '|cmd' pipe-style input files # 20091113 3.6 -d output-file plays through aplaymidi # 20091112 3.5 pad shifts from 0 ticks, stat output tidied # 20091107 3.4 mixer effect does channel-remapping e.g. 3:1 # 20091021 3.3 warns about mixing GM on and GM off or bank-select # 20091018 3.2 stat -freq detects screen width # 20091018 3.1 does the pan effect # 20091018 3.0 stat effect gets the -freq option # 20091015 2.9 does the mixer effect (channels ?) # 20091014 2.8 echo channels get panned right and left # 20091014 2.7 does the echo effect # 20091013 2.6 does the key effect # 20091013 2.5 midi2ms_score not opus2ms_score # 20091012 2.4 uses midi2ms_score # 20091011 2.3 fixed infinite loop in pad at the end # 20091010 2.2 to_millisecs() must now be called on the opus # 20091010 2.1 stat effect sorted, and more complete # 20091010 2.0 vol_mul() improves defensiveness and clarity # 20091010 1.9 the fade effect fades-out correctly # 20091010 1.8 does the fade effect, and trim works with one arg # 20091009 1.7 will read from - (i.e. stdin) # 20091009 1.6 does the repeat effect # 20091008 1.5 does -h, --help and --help-effect=NAME # 20091007 1.4 does the pad effect # 20091007 1.3 does the tempo effect # 20091007 1.2 will write to - (i.e. stdout), and does trim # 20091006 1.1 does sequence, concatenate and stat # 20091003 1.0 first working version, does merge and mix use Data::Dumper; $Data::Dumper::Indent = 0; $Data::Dumper::Sortkeys = 1; eval 'require MIDI'; if ($@) { die "you'll need to install the MIDI-Perl module from www.cpan.org\n"; } my $PID = 0; #----------------------------- Event stuff -------------------------- my %_sysex2midimode = ( "\x7E\x7F\x09\x01\xF7"=> 1, "\x7E\x7F\x09\x02\xF7"=> 0, "\x7E\x7F\x09\x03\xF7"=> 2, ); my %Event2channelindex = ( 'note'=>3, 'note_off'=>2, 'note_on'=>2, 'key_after_touch'=>2, 'control_change'=>2, 'patch_change'=>2, 'channel_after_touch'=>2, 'pitch_wheel_change'=>2 ); sub print_help { my $topic = $_[$[] || 'global'; my %help_dict = ( 'global'=><< Add echoing to the audio. Each delay decay pair gives the delay in milliseconds and the decay of that echo. Gain-in and gain-out are ignored, they are there for compatibilty with SoX. The echo effect triples the number of channels in the MIDI, so doesn't work well if there are more than 5 channels initially. E.g.: echo 1 1 240 0.6 450 0.3 EOT , 'fade'=><<< Reduces the number of MIDI channels, by selecting just some of them and combining these (if necessary) into one track. The channel parameters are the channel-numbers 0...15, for example mixer 9 selects just the drumkit. If an optional to_channel is specified, the selected channel will be remapped to the to_channel; for example, mixer 3:1 will select just channel 3 and renumber it to channel 1. The syntax of this effect is not the same as its SoX equivalent. EOT , 'pad'=><<<<<<< 0.0) { return int ($x + 0.5); } if ($x < 0.0) { return int ($x - 0.5); } return 0; } sub deepcopy { use Storable; if (1 == @_ and ref($_[$[])) { return Storable::dclone($_[$[]); } else { my $b_ref = Storable::dclone(\@_); return @$b_ref; } } sub vol_mul { my $vol = $_[$[] || 100; my $mul = $_[$[+1] || 1.0; my $new_vol = round($vol*$mul); if ($new_vol < 0) { $new_vol = 0 - $new_vol; } if ($new_vol > 127) { $new_vol = 127; } elsif ($new_vol < 1) { $new_vol = 1; # some synths interpret vol=0 as vol=default } return $new_vol; } my $UsingStdinAsAFile = 0; sub file2millisec { my $filename = $_[$[]; if ($filename eq '-n') { return([1000,[]]); } if ($filename =~ /^\|\s*(.+)/) { # 3.7 if (!open(P, "$1 |")) { _die("can't run $1: $!"); } my $opus_ref = MIDI::Opus->new({'from_handle' => *P{IO}}); # ugly cut-and-paste of file2opus code :-( my @my_opus = (${$opus_ref}{'ticks'},); foreach my $track ($opus_ref->tracks) { push @my_opus, $track->events_r; } close P; return opus2score(to_millisecs(@my_opus)); } if ($filename eq '-') { if ($UsingStdinAsAFile) { _die("can't read STDIN twice"); } $UsingStdinAsAFile = 1; } return file2ms_score($filename); } # ------------------------- effects --------------------------- sub echo { my ($score_ref, @params) = @_; my $h = ', see midisox --help-effect=echo'; if (4 > @params) { _die("echo needs at least 4 parameters$h"); } if (@params%2 == 1) { _die("echo needs an even number of parameters$h"); } my @score = @$score_ref; my %stats = score2stats(@score); my $nchannels = @{$stats{'channels_total'}}; if ($nchannels > 5) { warning("$nchannels channels is too many for echo effect"); } my @echo_scores = ($score_ref,); my $iparam = 2; my $iecho_score = 1; while ($iparam < @params) { my $param = $params[$iparam]; if ($param !~ /^[.0-9]+$/) { _die("echo: strange delay paramter: $param$h"); } my $delay = round($param); $iparam += 1; $param = $params[$iparam]; if ($param !~ /^[.0-9]+$/) { _die("echo: strange decay paramter: $param$h") } my $decay = 1.0 * $param; if ($iparam < 6) { my @delayed_score = timeshift(deepcopy(\@score), {'shift'=>$delay}); push @echo_scores, \@delayed_score; } my $itrack = 1; my $pan = 10 + 107*($iecho_score%2); while ($itrack < @{$echo_scores[-1]}) { my @extra_events = (); # pan the echo_tracks Left and Right respectively for $event_ref (@{$echo_scores[$iecho_score][$itrack]}) { my @event = @$event_ref; if ($event[0] eq 'note') { ${$event_ref}[5] = vol_mul($event[5], $decay); } elsif ($event[0] eq 'patch_change') { push @extra_events, ['control_change', $event[1]+6, $event[2], 10, $pan]; } elsif ($event[0] eq 'control_change' and $event[3] == 10) { ${$event_ref}[4] = $pan; } } push @{$echo_scores[$iecho_score][$itrack]}, @extra_events; $itrack += 1; } $iparam += 1; $iecho_score += 1; if ($iecho_score > 2) { $iecho_score = 1; } } return merge_scores(@echo_scores); } sub fade { my ($score_ref, @params) =@_; if (!@params) { _die('the fade effect needs a fade-in length'); } my $fade_in_ticks = round(1000*$params[0]); my $fade_out_ticks = $fade_in_ticks; # default my $stop_time_ticks = 0; if (1 < @params) { if ($params[1] =~ /^[.0-9]+$/) { $stop_time_ticks = round(1000*$params[1]); } else { _die("the fade effect's stop_time unrecognised: $params[1]"); } if (2 < @params) { if ($params[2] =~ /^[.0-9]+$/) { $fade_out_ticks = round(1000*$params[2]); } else { _die("the fade effect's fade_out_time unrecognised: $params[2]"); } } } if (($fade_in_ticks+$fade_out_ticks) > $stop_time_ticks) { warning('the fade-in overlaps the fade-out; see midisox --help-effect=fade'); } my @score = segment($score_ref, {'start'=>0, 'end'=>$stop_time_ticks}); my $itrack = 1; while ($itrack <= $#score) { foreach $event_ref (@{$score[$itrack]}) { my @event = @$event_ref; if ($event[0] eq 'note') { if ($event[1] < $fade_in_ticks) { ${$event_ref}[5] = vol_mul($event[5], $event[1]/$fade_in_ticks); } if ($event[1] > ($stop_time_ticks - $fade_out_ticks)) { ${$event_ref}[5] = vol_mul($event[5], ($stop_time_ticks-$event[1]) / $fade_out_ticks); } } } $itrack += 1; } return @score; } sub key { my ($score_ref, $cents) = @_; my @score = @$score_ref; my $itrack = $[+1; my $semitones = round(0.01 * $cents); while ($itrack <= $#score) { foreach my $event_ref (@{$score[$itrack]}) { my @event = @{$event_ref}; if ($event[0] eq 'note' and $event[3] != 9) { # don't shift drumkit ${$event_ref}[4] += $semitones; } } $itrack += 1; } return @score; } sub mixer { my ($score_ref, @params) = @_; my $h = ', see midisox --help-effect=mixer'; my @int_params = (); my %remap = (); if (!@params) { _die("mixer effect needs parameters$h"); } foreach my $param (@params) { if ($param =~ /^(\d+):(\d+)$/) { $remap{$1} = $2; $param = $1; } if ($param =~ /^\d+$/) { push @int_params, $param; } else { _die("mixer: unrecognised channel number $param$h"); } } #_warn("remap = ".Dumper(\%remap)); my @grepped_score = _grep($score_ref, {'channels'=>[@int_params],}); my $itrack = 1; while ($itrack < @grepped_score) { my $ievent = $[; foreach my $event_ref (@{$grepped_score[$itrack]}) { my @event = @$event_ref; my $channel_index = $Event2channelindex{$event[0]}; if ($channel_index and defined $remap{$event[$channel_index]}) { $grepped_score[$itrack][$ievent][$channel_index] = $remap{$event[$channel_index]}; } $ievent += 1; } $itrack += 1; } return mix_scores((\@grepped_score,)); } sub pad { my ($score_ref, @params) = @_; my @score = @$score_ref; if (2 > @$score_ref) { return (1000, [],); } my $i = 0; while ($i <= $#params) { my $param = $params[$i]; if ($param =~ /^(\d+\.?\d*)@(\d+\.?\d*)$/) { # XXX must apply these intermediate pads after any beginning pad my $shift = round(1000 * $1); my $from_time = round(1000 * $2); @score = timeshift(\@score, {'shift'=>$shift, 'from_time'=>$from_time}); } elsif ($param =~ /^[+.0-9]+$/) { my $shift = round(1000 * $param); if ($i == 0) { @score = timeshift(\@score, {'shift'=>$shift, 'from_time'=>0}); } elsif ($i == $#params) { my %stats = score2stats(@score); my $new_end_time = $shift + $stats{'nticks'}; my $itrack = 1; my $mark_string = "pad $param"; while ($itrack <= $#score) { push @{$score[$itrack]}, ['marker',$new_end_time,$mark_string]; $itrack += 1; } } else { _die('pad parameter $param should be either first or last'); } } else { _die("unrecognised pad parameter: $param"); } $i += 1; } return @score; } sub pan { my ($score_ref, $direction) = @_; my @score = @$score_ref; if ($direction > 1.00000001 or $direction < -1.00000001 or !defined $direction) { _die("pan parameter must be [-1.0 ... 1.0], was: $direction"); } my $itrack = 1; while ($itrack <= $#score) { my @extra_events = (); foreach my $event_ref (@{$score[$itrack]}) { my @event = @$event_ref; if ($event[0] eq 'control_change' and $event[3] == 10) { if ($direction < -0.00000001) { ${$event_ref}[4] = round($event[4] * (1.0+$direction)); } elsif ($direction > 0.00000001) { ${$event_ref}[4] += round((127-$event[4]) * $direction); } } elsif ($event[0] eq 'patch_change') { my $new_pan = round(63.5 + 63.5*$direction); push @extra_events, ['control_change', $event[1]+6, $event[2], 10, $new_pan]; } } push @{$score[$itrack]}, @extra_events; $itrack += 1; } return @score; } sub repeat { my ($score_ref, $count) = @_; my @score = @$score_ref; if (2 > @$score_ref) { return (1000, [],); } if ($count < 1) { _die("repeat's count parameter must be an integer: $count"); } $count = round($count); my @scores = ($score_ref,); my $i = 0; while ($i < $count) { push @scores, $score_ref; $i += 1; } return concatenate_scores(@scores); } sub _stat { my ($score_ref, @params) = @_; my %stats = score2stats(@{$score_ref}); if ($params[0] eq '-freq') { my $pmin = 127; my $pmax = 0; foreach my $p (keys %{$stats{'pitches'}}) { if ($p < $pmin) { $pmin = $p; } if ($p > $pmax) { $pmax = $p; } } my $nmax = 0; $p = $pmax; while ($p >= $pmin) { my $n = $stats{'pitches'}{$p}; if ($nmax < $n) { $nmax = $n; } $p -= 1; } my $nwidth = 1 + round(log($nmax)/log(10)); _warn('Pitch N'); # http://bytes.com/groups/python/607757-getting-terminal-display-size #s = struct.pack("HHHH", 0, 0, 0, 0) #try { # x = fcntl.ioctl(sys.stderr.fileno(), termios.TIOCGWINSZ, s) # [maxrows, maxcols, xpixels, ypixels] = struct.unpack("HHHH", x) #except { my $maxcols = 80; $p = $pmax; while ($p >= $pmin) { my $n = $stats{'pitches'}{$p}; my $bar; if ($nmax > ($maxcols-10-$nwidth)) { $bar = '#' x round(($maxcols-10-$nwidth)*$n/$nmax); } else { $bar = '#' x $n; } my $fmt = "%3d %".$nwidth."d $bar\n"; printf STDERR $fmt, $p,$n; $p -= 1; } } foreach $stat (sort keys %stats) { my $val = $stats{$stat}; if ($stat eq 'nticks') { print STDERR "nticks: $val = ". (0.001*$val) ." sec\n"; } elsif ($stat eq 'patch_changes_total') { my @l = (); foreach my $patchnum (sort keys %$val) { push @l, "$patchnum: $MIDI::number2patch{$patchnum}"; } _warn('patch_changes_total: {' . join(', ',@l) . '}'); } elsif (ref $val) { my $dump = Dumper($val); $dump =~ s/^\$VAR1 = //; $dump =~ s/ => /:/g; $dump =~ s/'(\d+)'/$1/g; $dump =~ s/,(\d+)/, $1/g; $dump =~ s/;$//g; print STDERR "$stat: $dump\n"; } else { print STDERR "$stat: $val\n"; } } return @score; } sub tempo { my ($score_ref, $tempo) = @_; if ($tempo < 0.1) { $tempo = 0.1; } foreach my $track_ref (@$score_ref) { for my $event_ref (@$track_ref) { ${$event_ref}[1] = round(${$event_ref}[1]/$tempo); if (${$event_ref}[0] eq 'note') { ${$event_ref}[2] = round(${$event_ref}[2]/$tempo); } } } return @$score_ref; } sub trim { my ($score_ref, $start, $_length) = @_; my $start_ticks = round(1000*$start); my $end_ticks = 100000000000; if ($_length) { $end_ticks = $start_ticks + round(1000*$_length); } my @tmp = segment($score_ref, {'start'=>$start_ticks, 'end'=>$end_ticks}); return timeshift(\@tmp, {'start_time'=>1}); } # --------------------------main ----------------------------- my %Possible_Combine = map { $_, 1 } ('concatenate','merge','mix','sequence'); my %Possible_Effect = map { $_, 1 } ('echo', 'fade','key', 'mixer','pad','pan','pitch','repeat','silence','stat','tempo','trim'); my @global_options = (); my @input_files = (); my @output_file = ([], ''); my @effects = (); # command-line options: my $Interactive_mode = 0; my $Combine_mode = 'sequence'; my $i = 0; while ($i < @ARGV) { $arg = $ARGV[$i]; if ($arg eq '--interactive') { $Interactive_mode = 1; } elsif ( $arg eq '--version') { _print("midisox version $Version $VersionDate"); exit(0); } elsif ( $arg eq '-h' or $arg eq '--help') { print_help(); exit(0); } elsif ($arg =~ /^--help-effect=([a-z]+)/) { print_help($1); exit(0); } elsif ( $arg eq '-m') { $Combine_mode = 'mix'; } elsif ( $arg eq '-M') { $Combine_mode = 'merge'; } elsif ( $arg eq '--combine') { $i += 1; if ($i >= @ARGV) { _die('--combine must be followed by something'); } $arg = $ARGV[$i]; if ($Possible_Combine{$arg}) { $Combine_mode = $arg; } else { _die('--combine must be followed by concatenate, merge, mix, or sequence'); } } else { last; } $i += 1; } my $volume = 1.0; # warn "i=$i ARGV=@ARGV\n"; while ($i < @ARGV) { # loop through all files, input and output... my $arg = $ARGV[$i]; # warn "arg=$arg\n"; if ($arg eq '--volume' or $arg eq '-v') { $i += 1; if ($i >= @ARGV) { _die("$arg must be followed by a volume, and an input file") } my $volume = 1.0 * $ARGV[$i]; if ($volume < 0.00000001) { _die('-v must be followed by a number (default volume is 1.0)') } } elsif ($Possible_Effect{$arg}) { last; # os.path.exists(arg) or arg eq '-': # or a pipe... # die('input file ' + arg + ' does not exist') might be output... # it's a filename } else { push @input_files, [$volume, $arg]; $volume = 1.0; } $i += 1; } # then the last of these files must be the output-file; pop it if (@input_files < 2) { _die('midisox needs at least one input-file and one output-file'); } my $output_file = pop @input_files; while ($i < @ARGV) { # loop through all effects... my $arg = $ARGV[$i]; if ($Possible_Effect{$arg}) { push @effects, [$arg]; } else { push @{$effects[-1]}, $arg; } $i += 1; } #print('Combine_mode = ' + str(Combine_mode)) #print('input_files='+str(input_files)) #print('output_file='+str(output_file)) #print "effects is ", Dumper(@effects), "\n"; # read input files in, and apply the input effects my @input_scores = (); my $gm_on_already = ''; my $gm_off_already = ''; my $bank_already = ''; foreach my $input_file_ref (@input_files) { my @input_file = @{$input_file_ref}; my @score = file2millisec($input_file[1]); # print "input_file[1]=$input_file[1] score is ", Dumper(@score), "\n"; # 3.3 detect incompatible GM-modes and warn... my %stats = score2stats(@score); foreach my $gm_mode (@{$stats{'general_midi_mode'}}) { if ($gm_mode == 0 and $gm_on_already) { warning("$gm_on_already turns GM on, but $input_file[1] turns it off"); } elsif ($gm_mode > 0 and $gm_off_already) { warning("$gm_off_already turns GM off, but $input_file[1] turns it on"); } elsif ($gm_mode > 0 and $bank_already) { warning("$bank_already selects a bank, but $input_file[1] turns GM on"); } elsif ($gm_mode == 0) { $gm_off_already = $input_file[1]; } elsif ($gm_mode > 0) { $gm_on_already = $input_file[1]; } } if ($stats{'bank_select'}) { if ($gm_on_already) { warning("$gm_on_already turns GM on, but $input_file[1] selects a bank"); } $bank_already = $input_file[1]; } $volume = 1.0 * $input_file[0]; if (volume < 0.99 or volume > 1.01) { my $itrack = 1; while ($itrack < @score) { my $ievent = 0; while ($ievent < @{$score[$itrack]}) { if ($score[$itrack][$ievent][0] eq 'note') { $score[$itrack][$ievent][5] = vol_mul($volume, $score[$itrack][$ievent][5]); } $ievent += 1; } $itrack += 1; } } push @input_scores, \@score; } # print "input_scores is ", Dumper(@input_scores); # combine the input scores into an output score if ($Combine_mode eq 'merge') { @output_score = merge_scores(@input_scores); } elsif ($Combine_mode eq 'mix') { @output_score = mix_scores(@input_scores); } elsif ($Combine_mode eq 'sequence' or $Combine_mode eq 'concatenate') { @output_score = concatenate_scores(@input_scores); } else { _die("unsupported combine mode: $Combine_mode"); } # print "output_score is ", Dumper(@output_score); # apply effects to the output score for my $effect_ref (@effects) { my @effect = @{$effect_ref}; if ($effect[0] eq 'echo') { @output_score = echo(\@output_score, @effect[1 .. $#effect]); } elsif ($effect[0] eq 'fade') { @output_score = fade(\@output_score, @effect[1 .. $#effect]); } elsif ($effect[0] eq 'key' || $effect[0] eq 'pitch') { @output_score = key(\@output_score, $effect[1]); } elsif ($effect[0] eq 'mixer') { @output_score = mixer(\@output_score, @effect[1 .. $#effect]); } elsif ($effect[0] eq 'pad') { @output_score = pad(\@output_score, @effect[1 .. $#effect]); } elsif ($effect[0] eq 'pan') { @output_score = pan(\@output_score, @effect[1 .. $#effect]); } elsif ($effect[0] eq 'repeat') { @output_score = repeat(\@output_score, @effect[1 .. $#effect]); } elsif ($effect[0] eq 'stat') { _stat(\@output_score, @effect[1 .. $#effect]); } elsif ($effect[0] eq 'tempo') { my $effect1 = $effect[1] || 1.0; @output_score = tempo(\@output_score, $effect1); } elsif ($effect[0] eq 'trim') { my $effect1 = $effect[1] || 0; my $effect2 = $effect[2]; @output_score = trim(\@output_score, $effect1, $effect2); } else { _die("unrecognised effect: @effect"); } } # open the output file and print the output score to it if (${$output_file}[1] eq '-n') { exit(0); } if (${$output_file}[1] eq '-') { score2file('-', @output_score); exit 0; } if ($Interactive_mode and -e ${$output_file}[1]) { require Term::Clui; Term::Clui::confirm("OK to overwrite ${$output_file}[1] ?") or exit 0; } score2file(${$output_file}[1], @output_score); # if ($PID) { warn "waiting\n"; wait $PID; } exit(0); #------------------------------- Encoding stuff -------------------------- sub opus2file { my ($filename, @opus) = @_; # print "opus2file: filename=$filename opus = ", Dumper(@opus); my $format = 1; if (2 == @opus) { $format = 0; } my $cpan_opus = MIDI::Opus->new( {'format'=>$format, 'ticks' => 1000, 'tracks' => []}); # my $tracks_r = $cpan_opus->tracks_r(); my @list_of_tracks = (); my $itrack = $[+1; while ($itrack <= $#opus) { push @list_of_tracks, MIDI::Track->new({ 'type' => 'MTrk', 'events' => $opus[$itrack]}); $itrack += 1; } # print "opus2file: list_of_tracks = ", Dumper(@list_of_tracks); $cpan_opus->tracks(@list_of_tracks); # $cpan_opus->dump({'dump_tracks'=>1}); if ($filename eq '-') { $cpan_opus->write_to_file( '>-' ); # $cpan_opus->write_to_handle({'to_handle' => *STDOUT{IO}}); } elsif ($filename eq '-d') { $PID = fork; if (! $PID) { if (!open(P, '| aplaymidi -')) { die "can't run aplaymidi: $!\n"; } $cpan_opus->write_to_handle( *P{IO}, {} ); close P; exit 0; } } else { $cpan_opus->write_to_file($filename); } } sub score2opus { if (2 > @_) { return (1000, []); } my ($ticks, @tracks) = @_; # print "score2opus: tracks is ", Dumper(@tracks); my @opus = ($ticks,); my $itrack = $[; while ($itrack <= $#tracks) { # MIDI::Score::dump_score( $_[$itrack] ); # push @opus, MIDI::Score::score_r_to_events_r($_[$itrack]); my %time2events = (); foreach my $scoreevent_ref (@{$tracks[$itrack]}) { my @scoreevent = @{$scoreevent_ref}; # print "score2opus: scoreevent = @scoreevent\n"; if ($scoreevent[0] eq 'note') { my @note_on_event = ('note_on',$scoreevent[1], $scoreevent[3],$scoreevent[4],$scoreevent[5]); my @note_off_event = ('note_off',$scoreevent[1]+$scoreevent[2], $scoreevent[3],$scoreevent[4],$scoreevent[5]); if ($time2events{$note_on_event[1]}) { push @{$time2events{$note_on_event[1]}}, \@note_on_event; } else { @{$time2events{$note_on_event[1]}} = (\@note_on_event,); } if ($time2events{$note_off_event[1]}) { push @{$time2events{$note_off_event[1]}}, \@note_off_event; } else { @{$time2events{$note_off_event[1]}} = (\@note_off_event,); } } elsif ($time2events{$scoreevent[1]}) { push @{$time2events{$scoreevent[1]}}, \@scoreevent; } else { @{$time2events{$scoreevent[1]}} = (\@scoreevent,); } } my @sorted_events = (); # list of event_refs sorted by time for my $time (sort {$a <=> $b} keys %time2events) { push @sorted_events, @{$time2events{$time}}; } my $abs_time = 0; for my $event_ref (@sorted_events) { # convert abs times => delta times my $delta_time = ${$event_ref}[1] - $abs_time; $abs_time = ${$event_ref}[1]; ${$event_ref}[1] = $delta_time; } push @opus, \@sorted_events; $itrack += 1; } return (@opus); } sub score2file { my ($filename, @score) = @_; my @opus = score2opus(@score); return opus2file($filename, @opus); } #--------------------------- Decoding stuff ------------------------ sub file2opus { my $opus_ref; if ($_[$[] eq '-') { $opus_ref = MIDI::Opus->new({'from_handle' => *STDIN{IO}}); } elsif ($_[$[] =~ /^[a-z]+:\//) { eval 'require LWP::Simple'; if ($@) { _die "you'll need to install libwww-perl from www.cpan.org"; } $midi = LWP::Simple::get($_[$[]); if (! defined $midi) { _die("can't fetch $_[$[]"); } open(P, '<', \$midi) or _die("can't open FileHandle, need Perl5.8"); $opus_ref = MIDI::Opus->new({'from_handle' => *P{IO}}); close P; } else { $opus_ref = MIDI::Opus->new({'from_file' => $_[$[]}); } # $opus_ref->dump({'dump_tracks'=>1}); my @my_opus = (${$opus_ref}{'ticks'},); foreach my $track ($opus_ref->tracks) { push @my_opus, $track->events_r; } # print "3:\n", Dumper(\@my_opus); return @my_opus; } sub opus2score { my ($ticks, @opus_tracks) = @_; # print "opus2score: ticks=$ticks opus_tracks=@opus_tracks\n"; if (!@opus_tracks) { return (1000,[],); } my @score = ($ticks,); #foreach my $i ($[+1 .. $#_) { # push @score, MIDI::Score::events_r_to_score_r($score[$i]); #} my @tracks = deepcopy(@opus_tracks); # couple of slices probably quicker... # print "opus2score: tracks is ", Dumper(@tracks); foreach my $opus_track_ref (@tracks) { my $ticks_so_far = 0; my @score_track = (); my %pitch2note_on_events = (); foreach $opus_event_ref (@{$opus_track_ref}) { my @opus_event = @{$opus_event_ref}; $ticks_so_far += $opus_event[1]; if ($opus_event[0] eq 'note_on') { my $pitch = $opus_event[3]; my $new_event_ref = ['note', $ticks_so_far, 0, $opus_event[2], $pitch, $opus_event[4]]; push @{$pitch2note_on_events{$pitch}}, $new_event_ref; } elsif ($opus_event[0] eq 'note_off') { my $pitch = $opus_event[3]; if ($pitch2note_on_events{$pitch}) { my $new_event_ref = shift @{$pitch2note_on_events{$pitch}}; ${$new_event_ref}[2] = $ticks_so_far - ${$new_event_ref}[1]; push @score_track, $new_event_ref; } else { _warn("note_off without a note_on, pitch=$pitch") } } else { $opus_event[1] = $ticks_so_far; push @score_track, \@opus_event; } } push @score, \@score_track; } # should check for unterminated notes ? return @score; } sub file2score { return opus2score(file2opus(midi)); } sub file2ms_score { #print "file2ms_score(@_)\n"; # return opus2score(to_millisecs(file2opus($_[$[]))); my @opus = file2opus($_[$[]); my @ms = to_millisecs(@opus); my @score = opus2score(@ms); return @score; } #------------------------ Other Transformations --------------------- sub to_millisecs { my @old_opus = @_; if (!@old_opus) { return (1000,[],); } my $old_tpq = $_[$[]; my @new_opus = (1000,); my $millisec_per_old_tick = 1000.0 / $old_tpq; # float: will round later $itrack = $[+1; while ($itrack <= $#old_opus) { my $millisec_so_far = 0.0; my $previous_millisec_so_far = 0.0; my @new_track = (['set_tempo',0,1000000],); # new "crochet" is 1 sec foreach my $old_event_ref (@{$old_opus[$itrack]}) { my @old_event = @{$old_event_ref}; # print "to_millisecs: old_event = @old_event\n"; if ($old_event[0] eq 'note') { _die 'to_millisecs needs an opus, not a score'; } my @new_event = deepcopy(@old_event); # copy.deepcopy ? $millisec_so_far += ($millisec_per_old_tick * $old_event[1]); $new_event[1] = round($millisec_so_far-$previous_millisec_so_far); if ($old_event[0] eq 'set_tempo') { $millisec_per_old_tick = $old_event[2] / (1000.0 * $old_tpq); } else { $previous_millisec_so_far = $millisec_so_far; push @new_track, \@new_event; } } push @new_opus, \@new_track; $itrack += 1; } # print "to_millisecs new_opus = ", Dumper(\@new_opus); return @new_opus; } sub _grep { my ($score_ref, $args_ref) = @_; my @score = @$score_ref; my @channels = @{${$args_ref}{'channels'}}; my %channels = map { $_, 1 } @channels; if (2 > @$score_ref) { return (1000, [],); } my @new_score = ($score[0],); if (!%channels) { return @new_score; } $itrack = 1; while ($itrack <= $#score) { push (@new_score, []); foreach my $event_ref (@{$score[$itrack]}) { my @event = @$event_ref; my $channel_index = $Event2channelindex{$event[0]}; if ($channel_index) { if ($channels{$event[$channel_index]}) { push @{$new_score[$itrack]}, $event_ref; } } else { push @{$new_score[$itrack]}, $event_ref; } } $itrack += 1; } return @new_score; } sub timeshift { my ($score_ref, $args_ref) = @_; my @score = @$score_ref; my $shift = ${$args_ref}{'shift'}; my $start_time = ${$args_ref}{'start_time'}; my $from_time = ${$args_ref}{'from_time'}; my @tracks = @{${$args_ref}{'tracks'}}; if (2 > @$score_ref) { return (1000, [],); } my @new_score = ($score[0],); my $my_type = score_type(@score); if (!$my_type) { return @new_score; } if ($my_type eq 'opus') { _warn("timeshift: opus format is not supported\n"); return @new_score; } if ($shift and $start_time) { _warn("timeshift: shift and start_time specified: ignoring shift"); undef $shift; } if (!defined $shift) { if ($start_time <= 0) { $start_time = 0; } # shift = start_time - from_time } my $itrack = 1; # ignore first element (ticks) my %tracks = map { $_, 1 } @tracks; my $earliest = 1000000000; if ($start_time or $shift<0) { # first find the earliest event while ($itrack < @score) { if (@tracks and !$tracks{$itrack-1}) { $itrack += 1; next; } foreach my $event_ref (@{$score[$itrack]}) { if (${$event_ref}[1] < $from_time) { next; # just inspect the to_be_shifted events } if (${$event_ref}[1] < $earliest) { $earliest = ${$event_ref}[1]; } } $itrack += 1; } } if (!$shift) { $shift = $start_time - $earliest; } elsif (($earliest + $shift) < 0) { $start_time = 0; $shift = 0 - $earliest; } $itrack = 1; # ignore first element (ticks) while ($itrack < @score) { if (@tracks and !$tracks{$itrack-1}) { push @new_score, $score[$itrack]; $itrack += 1; next; } my @new_track = (); foreach my $event_ref (@{$score[$itrack]}) { my @new_event = @$event_ref; if ($new_event[1] >= $from_time) { $new_event[1] += $shift; } elsif ($shift < 0 and $new_event[1] >= ($from_time+$shift)) { next; } push @new_track, \@new_event; } if (@new_track) { push @new_score, \@new_track; } $itrack += 1; } return @new_score; } sub segment { my ($score_ref, $args_ref) = @_; # Returns a "score" which is a segment of the one supplied as # the argument, beginning at "start" ticks and ending at "end" # ticks (or at the end if "end" is not supplied). If the listref # "tracks" is specified, only those tracks will be returned. my @score = @$score_ref; my $start = ${$args_ref}{'start'}; my $end = ${$args_ref}{'end'}; my @tracks = @{${$args_ref}{'tracks'}}; if (2 > @$score_ref) { return (1000, [],); } #print('score: start='+str(start)+' end='+str(end), file=sys.stderr) my @new_score = ($score[0],); my $my_type = score_type(@score); if (!$my_type) { return @new_score; } if ($my_type eq 'opus') { # more difficult (disconnecting note_on's from their note_off's)... _warn("segment: opus format is not supported"); return @new_score; } my $itrack = 1; # ignore first element (ticks); we count in ticks anyway my %tracks = map { $_, 1 } @tracks; while ($itrack <= $#score) { if (@tracks and !$tracks{$itrack-1}) { $itrack += 1; next; } my @new_track = (); my %channel2patch_num; # keep most recent patch change before start my %channel2patch_time; my $set_tempo_num = 1000000; # most recent tempo change before start my $set_tempo_time = 0; my $earliest_note_time = $end; for my $event_ref (@{$score[$itrack]}) { my @event = @$event_ref; if ($event[0] eq 'patch_change') { my $patch_time = $channel2patch_time{$event[2]} || 0; if ($event[1] < $start and $event[1] >= $atch_time) { # 2.0 $channel2patch_num{$event[2]} = $event[3]; $channel2patch_time{$event[2]} = $event[1]; } } elsif ($event[0] eq 'set_tempo') { if ($event[1] < $start and $event[1] >= $set_tempo_time) { $set_tempo_num = $event[2]; $set_tempo_time = $event[1]; } } if ($event[1] >= $start and $event[1] <= $end) { push @new_track, \@event; if ($event[0] eq 'note' and $event[1] < $earliest_note_time) { $earliest_note_time = $event[1]; } } } if (@new_track) { foreach my $c (sort keys %channel2patch_num) { push @new_track, ['patch_change',$start,$c,$channel2patch_num{$c}]; } push @new_track, ['set_tempo', $start, $set_tempo_num]; push @new_score, \@new_track; } $itrack += 1; } return @new_score; } sub score_type { my @opus_or_score = @_; # Returns a string, either 'opus' or 'score' or '' if (@opus_or_score < 2) { return ''; } my $itrack = $[+1; # ignore first element while ($itrack <=$#opus_or_score) { foreach my $event_ref (@{$opus_or_score[$itrack]}) { my @event = @$event_ref; if ($event[0] eq 'note') { return 'score'; } elsif ($event[0] eq 'note_on') { return 'opus'; } } $itrack += 1; } return ''; } #sub sort_score(score=None): # for each track, # $score2_r = MIDI::Score::sort_score_r( $score_r) LoL # return [ticks, [],] sub concatenate_scores { my @input_scores = @_; # Concatenates a list of scores into one score. # the deepcopys are needed if the input_scores are refs to the same obj # e.g. if invoked by midisoxs repeat() # print "concatenate_scores: input_scores is ", Dumper(@_); my @output_score = (1000,); my $iscore = $[; while ($iscore <= $#input_scores) { my @input_score = @{$input_scores[$iscore]}; my %output_stats = score2stats(@output_score); my $delta_ticks = $output_stats{'nticks'}; my $itrack = $[+1; while ($itrack < @input_score) { if ($itrack >= @output_score) { # new track if doesnt exist push @output_score, []; } for $event (@{$input_score[$itrack]}) { push @{$output_score[$itrack]}, deepcopy($event); $output_score[$itrack][-1][1] += $delta_ticks; } $itrack += 1; } $iscore += 1; } # print "concatenate_scores: output_score is ", Dumper(@output_score); return @output_score; } sub merge_scores { # Merges a list of scores into one score. A merged score comprises # all of the tracks from all of the input scores; un-merging is possible # by selecting just some of the tracks. The scores should be in # millisecond-tick format; they will get converted if necessary, but # this is a slow process. merge_scores attempts to resolve channel- # -conflicts, but there are of course only 15 available channels... my @output_score = (1000,); my %channels_so_far = (); my %all_channels = map { $_, 1 } (0,1,2,3,4,5,6,7,8,10,11,12,13,14,15); foreach $input_score_ref (@_) { my @input_score = @$input_score_ref; if ($input_score[0] != 1000) { _warn("not millisecs already?"); @input_score = opus2score(to_millisec(score2opus(@input_score))); } my %stats = score2stats(@input_score); my %new_channels = map { $_, 1 } @{$stats{'channels_total'}}; delete $new_channels{9}; # 2.8 cha9 must remain cha9 (in GM) my @so_far_and_new = (); foreach (sort keys %all_channels) { if ($channels_so_far{$_} and $new_channels{$_}) { push @so_far_and_new, $_; } } foreach my $channel (@so_far_and_new) { # free_channels = all_channels - (channels_so_far|new_channels) my @free_channels = (); foreach (keys %all_channels) { if (!$channels_so_far{$_} and !$new_channels{$_}) { push @free_channels, $_; } } @free_channels = sort {$a<=>$b} @free_channels; # print "free_channels is ", Dumper(\@free_channels), "\n"; last unless @free_channels; my $free_channel = $free_channels[$[]; my $itrack = 1; while ($itrack <= $#input_score) { for my $input_event_ref (@{$input_score[$itrack]}) { my @input_event = @$input_event_ref; $channel_index = $Event2channelindex{$input_event[0]}; if ($channel_index and $input_event[$channel_index]==$channel) { ${$input_event_ref}[$channel_index] = $free_channel; } } $itrack += 1; } $channels_so_far{$free_channel} = 1; } # channels_so_far |= new_channels foreach (keys %new_channels) { $channels_so_far{$_} = 1; } push @output_score, @input_score[1..$#input_score]; } return @output_score; } sub mix_scores { my @output_score = (1000, []); for my $input_score_ref (@_) { my @input_score = @$input_score_ref; my $itrack = $[+1; while ($itrack <= $#input_score) { push @{$output_score[1]}, @{$input_score[$itrack]}; $itrack += 1; } } return @output_score; } sub score2stats { my @opus_or_score = @_; #Returns a dict of some basic stats about the score, like #bank_select (list of tuples (msb,lsb)), #channels_by_track (list of lists), channels_total (set), #general_midi_mode (list), #ntracks, nticks, patch_changes_by_track (list of dicts), #patch_changes_total (set), #percussion (dict histogram of channel 9 events), #pitches (dict histogram of pitches on channels other than 9), #pitch_range_by_track (list, by track, of two-member-tuples), #pitch_range_sum (sum over tracks of the pitch_ranges), my $bank_select_msb = -1; my $bank_select_lsb = -1; my $bank_select = []; my @channels_by_track = (); my %channels_total = (); my @general_midi_mode = (); my @patches_used_by_track = (); my %patches_used_total = (); my @patch_changes_by_track = (); my %patch_changes_total = (); my %percussion = (); # histogram of channel 9 "pitches" my %pitches = (); # histogram of pitch-occurrences channels 0-8,10-15 my $pitch_range_sum = 0; # u pitch-ranges of each track my @pitch_range_by_track = (); my $is_a_score = True; if (!@opus_or_score) { return {'bank_select'=>[], 'channels_by_track'=>[], 'channels_total'=>[], 'general_midi_mode'=>(), 'ntracks'=>0, 'nticks'=>0, 'patch_changes_by_track'=>[], 'patch_changes_total'=>[], 'percussion'=>{}, 'pitches'=>{}, 'pitch_range_by_track'=>[], 'ticks_per_quarter'=>0, 'pitch_range_sum'=>0}; } $ticks_per_quarter = $opus_or_score[0]; $i = $[+1; # ignore first element, which is ticks $nticks = 0; while ($i < @opus_or_score) { $highest_pitch = 0; $lowest_pitch = 128; %channels_this_track = (); %patch_changes_this_track = (); for $event_ref (@{$opus_or_score[$i]}) { my @event = @$event_ref; if ($event[0] eq 'note') { if ($event[3] == 9) { $percussion{$event[4]} += 1; } else { $pitches{$event[4]} += 1; if ($event[4] > $highest_pitch) { $highest_pitch = $event[4]; } elsif ($event[4] < $lowest_pitch) { $lowest_pitch = $event[4]; } } $channels_this_track{$event[3]} = 1; $channels_total{$event[3]} = 1; my $finish_time = $event[1] + $event[2]; if ($finish_time > $nticks) { $nticks = $finish_time; } } elsif ($event[0] eq 'note_on') { $is_a_score = 0; if ($event[2] == 9) { $percussion{$event[3]} += 1; } else { $pitches{$event[3]} += 1; if ($event[3] > $highest_pitch) { $highest_pitch = $event[3]; } elsif ($event[3] < $lowest_pitch) { $lowest_pitch = $event[3]; } } $channels_this_track{$event[2]} = 1; $channels_total{$event[2]} = 1; } elsif ($event[0] eq 'note_off') { my $finish_time = $event[1]; if ($finish_time > $nticks) { $nticks = $finish_time; } } elsif ($event[0] eq 'patch_change') { $patch_changes_this_track{$event[2]} = $event[3]; $patch_changes_total{$event[3]} = 1; } elsif ($event[0] eq 'control_change') { if ($event[3] == 0) { # bank select MSB $bank_select_msb = $event[4]; } elsif ($event[3] == 32) { # bank select LSB $bank_select_lsb = $event[4]; } if ($bank_select_msb >= 0 and $bank_select_lsb >= 0) { $bank_select{"$bank_select_msb, $bank_select_lsb"} = 1; $bank_select_msb = -1; $bank_select_lsb = -1; } } elsif ($event[0] eq 'sysex_f0') { if (defined $_sysex2midimode{$event[2]}) { push @general_midi_mode, $_sysex2midimode{$event[2]}; } } if ($is_a_score) { if ($event[1] > $nticks) { $nticks = $event[1]; } } else { $nticks += $event[1]; } } if ($lowest_pitch == 128) { $lowest_pitch = 0; } my @channels_this_track_list = sort keys %channels_this_track; $channels_by_track[$i-1] = \@channels_this_track_list; my @patch_changes_this_track_list = sort keys %patch_changes_this_track; push @patch_changes_by_track, \%patch_changes_this_track; push @pitch_range_by_track, [$lowest_pitch,$highest_pitch]; $pitch_range_sum += ($highest_pitch-$lowest_pitch); $i += 1; } my @channels_total = sort {$a<=>$b} keys %channels_total; my @bank_select = sort {$a<=>$b} keys %bank_select; return ('bank_select' => \@bank_select, 'channels_by_track' => \@channels_by_track, 'channels_total' => \@channels_total, 'general_midi_mode' => $general_midi_mode, 'ntracks' => (-1 + @opus_or_score), 'nticks' => $nticks, 'patch_changes_by_track' => \@patch_changes_by_track, 'patch_changes_total' => \%patch_changes_total, 'percussion' => \%percussion, 'pitches' => \%pitches, 'pitch_range_by_track' => \@pitch_range_by_track, 'pitch_range_sum' => $pitch_range_sum, 'ticks_per_quarter' => $ticks_per_quarter ); } =pod =head1 NAME midisox - a SoX-like workalike, for handling MIDI files =head1 SYNOPSIS > midisox [global-options] \ [format-options] infile1 [[format-options] infile2] ... \ [format-options] outfile \ [effect [effect-options]] ... > sox chorus.wav chorus.wav mid8.wav chorus.wav out.wav > play out.wav > midisox chorus.mid chorus.mid mid8.mid chorus.mid out.mid > aplaymidi out.mid > midisox -M bass.mid pno.mid -v 1.1 horns.mid soar.mid verse.mid > midisox -M bass.mid pno.mid voice.mid - | aplaymidi - > muscript -midi chords | midisox -M - bass.mid - | aplaymidi - > muscript -midi chords | midisox - -n stat > midisox -M "|midisox chords.mid - pitch -200" solo.mid out.mid > midisox impro.mid riff.mid trim 37.2 3.4 > midisox --help ; midisox --help-effect=all =head1 DESCRIPTION Midisox is a tool for working on MIDI files, with a calling interface modelled, as far as possible, on that of SoX, which is a well-established tool for working on audio files. Midisox standardises all its files to a tick-rate of 1000 ticks/sec. This makes it possible to mix them together. But it does make it hard to load them into music-typesetting software afterwards and have the beats recognised. . . Midisox assumes at various places that it is working on a General-Midi file: for example, the pitch effect will not try to transpose the drumkit on Channel 9. Midisox is now available in two versions, one written in Python3 and the other in Perl. This is midisox version 3.5 =head1 GLOBAL OPTIONS =over 3 =item I<-h, --help> Show version number and Belpful usage information, =item I<--help-effect=NAME> Show usage information on the specified effect (or "all"). =item I<--interactive> Prompt before overwriting an existing file. =item I<-m | -M | --combine concatenate|merge|mix|sequence> Select the input file combining method; -m means I, -M I. If the I combining method is selected (with B<-m>) then two or more input files must be given and will all be mixed together into one MIDI-track. A mixed file cannot be un-mixed. If the I combining method is selected (with B<-M>), then the merged file contains all of the MIDI-tracks from all of the input files; un-merging is possible using multiple invocations of I with the I effect. The merging process attempts to avoid channel-conflicts by renumbering channels in the later files as necessary (however, a total of only fifteen MIDI-channels is available). The default is I. =item I<--version> Displays the version number. =back =head1 INPUT AND OUTPUT FILES AND THEIR OPTIONS There is only one file-format-option available: =over 3 =item I<-v, --volume FACTOR> Adjust the volume (specifically, the velocity parameter of all the notes) by a factor of FACTOR. A factor less than 1 decreases the volume; greater than 1 increases it. =back Files can be either filenames, or B<- > meaning STDIN or STDOUT accordingly, or B<"|program [options] ..."> uses I's stdout as an input file B will fetch a URL as an input file B<-d> meaning the "default" output, i.e. will feed into I B<-n> meaning a null output-device (useful with the stat effect). =head1 EFFECTS: echo, fade, key, mixer, pad, pan, pitch, repeat, stat, tempo, trim =over 3 =item echo gain-in gain-out Add echoing to the audio. Each I pair gives the delay in milliseconds and the decay of that echo. I and I are ignored, they are there for compatibilty with SoX. The echo effect triples the number of channels in the MIDI, so doesn't work well if there are more than 5 channels initially. E.g.: echo 1 1 240 0.6 450 0.3 =item fade fade-in-length [stop-time [fade-out-length]] Adds a fade effect to the beginning, end, or both of the MIDI. Fade-ins start from the beginning and ramp the volume (specifically, the I parameter of all the notes) from zero to full, over I seconds. Specify 0 seconds if no fade-in is wanted. For fade-outs, the MIDI will be truncated at I, and the volume will be ramped from full down to zero starting at I seconds before the I. If I is not specified, it defaults to the same value as I. No fade-out is performed if I is not specified. Times are specified in seconds: I =item key shift Change the key (i.e. pitch but not tempo). This is just an alias for the B effect. =item mixer < channel[:to_channel] > Reduces the number of MIDI channels, by selecting just some of them and combining these (if necessary) into one track. The I parameters are the channel-numbers 0 ... 15, for example I selects just the drumkit. If an optional I is specified, the selected I will be remapped to the I; for example, I will select just channel 3 and renumber it to channel 1. The syntax of this effect is not the same as its SoX equivalent. =item pad { length[@position] } or pad length_at_start length_at_end Pads the MIDI with silence, at the beginning, the end, or at specified points within the file. Both length and position are specified in seconds. I is the amount of silence to insert, and position the position at which to insert it. Any number of lengths and positions may be specified, provided that each specified I is not less that the previous one. I is optional for the first and last lengths specified, and if omitted they correspond to the beginning and end respectively. For example: I adds two seconds of silence at each end, whilst I inserts 2.5 seconds of silence 3 minutes into the MIDI. If silence is wanted only at the end, specify a zero-length pad at the start. =item pan direction Pans all the MIDI-channels from one side to another. The I is a value from -1 to 1; -1 represents far-left and 1 represents far-right. =item pitch shift Changes the pitch (i.e. key but not tempo). I gives the pitch-shift, as positive or negative "cents" (i.e. 100ths of a semitone). However, currently all pitch-shifts get rounded to the nearest 100 cents, i.e. to the nearest semitone. =item repeat count Repeat the entire MIDI I times. Note that repeating one time doubles the length: the original MIDI plus the one repeat. =item stat [-freq] Does a statistical check on the MIDI, and prints results on stderr. The MIDI is passed unmodified through the processing chain. The I<-freq> option calculates the input's MIDI-pitch-spectrum (60 = middle-C) and prints it to stderr before the rest of the stats. =item tempo factor Changes the tempo (but not the pitch). I gives the ratio of new tempo to the old tempo. So if I > 1.0, then the MIDI will be speeded up. =item trim start [length] Outputs only the segment of the file starting at I seconds, and ending I seconds later, or at the end if I is not specified. To preserve instruments, however, the lastest patch-setting event in each channel is preserved, even if it occurred before the start of the segment. =back =head1 DOWNLOAD B The current version of midisox is available by http at http://www.pjb.com.au/midi/free/midisox To install midisox, save it to disc, move it into your $PATH, make it executable, and if necessary edit the first line to reflect where python3 is installed on your system. You will also need to install the MIDI.py and TermClui.py modules in your $PYTHONPATH. B The current version of midisox_pl is available by http at http://www.pjb.com.au/midi/free/midisox_pl To install it, save it to disc, rename it midisox, move it into your $PATH, make it executable, and if necessary edit the first line to reflect where perl is installed on your system. You will also need to install the MIDI-Perl and Term::Clui CPAN modules =head1 AUTHOR Peter J Billam www.pjb.com.au/comp/contact.html =head1 REQUIREMENTS * The Python 3 version requires MIDI.py and TermClui.py * The Perl version requires MIDI-Perl and Term::Clui __________________________________________________________________ =head1 SEE ALSO http://sox.sourceforge.net http://www.pjb.com.au/midi/MIDI.html http://www.pjb.com.au/midi/TermClui.html http://search.cpan.org/~sburke http://search.cpan.org/perldoc?Term::Clui http://www.pjb.com.au/midi =cut