💾 Archived View for thrig.me › blog › 2024 › 03 › 31 › midi2dev.pl captured on 2024-07-09 at 02:20:00.

View Raw

More Information

⬅️ Previous capture (2024-05-10)

-=-=-=-=-=-=-

#!/usr/bin/env perl
#
# midi2dev.pl - send MIDI events to a device, with hopefully suitable
# delays; a problem with midicat(1) is that it sends the events without
# delays, which is maybe not so good for playback. 
#
#   midi2dev.pl -s 500000 foo.midi # default tempo
#   midi2dev.pl -b 144    foo.midi # tempo by BPM (quarter notes)
#   midi2dev.pl -n        foo.midi # preview, does not open MIDI device
#
# Probably MIDI::Score could be used instead of manually parsing the
# track events, though I'm not sure whether it combines different track
# events to play at the same time.

use 5.36.0;
use Data::Dumper;
use Getopt::Std 'getopts';
use MIDI;
use Time::HiRes 'usleep';
use constant {
    NAME  => 0,    # slot into a MIDI::Event array
    DTIME => 1,
    CHAN  => 2,
};

getopts( 'nb:s:', \my %options ) or exit 1;

my $filename = shift // die "Usage: $0 midi-file [midi-device]\n";
my $dev      = shift // '/dev/rmidi0';
my $fh;
unless ( $options{n} ) {
    open $fh, '>', $dev or die "open '$dev': $!\n";
    binmode $fh;
    $fh->autoflush(1);
}
if ( exists $options{b} ) {
    # BPM in quarter notes; see Music::Tempo for where this code came
    # from, though the conversion here is for usleep, not to ms.
    die "$0: cannot use -b and -s together\n" if exists $options{s};
    die "$0: -b must be a value >= 1\n" unless $options{b} >= 1;
    $options{s} = 240000_000 / ( $options{b} * 4 );
} elsif ( exists $options{s} ) {
    die "$0: -s must be >= 1\n" unless $options{s} >= 1;
} else {
    $options{s} = 500_000;
}

my $opus   = MIDI::Opus->new( { from_file => $filename } );
my $ticks  = $opus->ticks;
my $format = $opus->format;
# Until I figure out what format 2 "sequentially independent single
# track patterns" means, or any other formats that may be out there.
die "$0: unsupported format $format\n" unless 0 <= $format <= 1;

# conversion between dtime (from midi events) to usleep
my $dtime2usec = $ticks / $options{s};

$Data::Dumper::Indent    = 0;
$Data::Dumper::Sortkeys  = 1;
$Data::Dumper::Terse     = 1;
$Data::Dumper::Useqq     = 1;

my $time   = 0;    # global dtime into the MIDI file (for reference)
# Events are references of events for each track; events_dtime holds
# the duration (dtime) to the next event in the track, and credit is so
# that longer events actually get played alongside shorter events in
# other tracks.
my @events = map { $_->events_r } $opus->tracks_r->@*;
my @events_dtime;
my @credit = (0) x @events;

while (1) {
    # Drain events that have 0 dtime, and record when the next event for
    # the track is.
    for my $i ( 0 .. $#events ) {
        $events_dtime[$i] = ~0;
        drain( $i, $events[$i] );
    }
    # Find what to play next (and how long to wait for it).
    my $min = ~0;
    for my $dtime (@events_dtime) {
        $min = $dtime if $dtime < $min;
    }
    if ( $min == ~0 ) {
        warn "notice: no remaining events (global time $time)\n";
        last;
    }
    $time += $min;
    # May want a factor here so things can be sped up indepdent of tempo
    # changes, e.g. "/ 4" for a faster playback.
    usleep( $min / $dtime2usec );
    # Consume (play) any minimum duration events.
    for my $i ( 0 .. $#events ) {
        advance( $i, $events[$i], $min ) if @{ $events[$i] };
    }
}

sub advance ( $track_number, $event_list, $min_dtime ) {
    my $dtime = $event_list->[0][DTIME] - $credit[$track_number];
    if ( $dtime == $min_dtime ) {
        play( $track_number, shift @$event_list );
        $credit[$track_number] = 0;
    } else {
        $credit[$track_number] += $min_dtime;
    }
}

sub drain ( $track_number, $event_list ) {
    while (1) {
        last unless @$event_list;
        my $dtime = $event_list->[0][DTIME];
        if ( $dtime == 0 ) {
            play( $track_number, shift @$event_list );
            $credit[$track_number] = 0;
        } else {
            $events_dtime[$track_number] = $dtime - $credit[$track_number];
            last;
        }
    }
}

# The MIDI::Event documentation details the array slots being used here.
sub play ( $track_number, $event ) {
    if ( $event->[0] eq 'note_on' ) {
        #say "$track_number\t", $event->[NAME], "\t", Dumper($event);
        printf $fh "%c%c%c", 0x90 | $event->[CHAN], $event->[3], $event->[4]
          if $fh;
    } elsif ( $event->[0] eq 'note_off' ) {
        #say "$track_number\t", $event->[NAME], "\t", Dumper($event);
        printf $fh "%c%c%c", 0x80 | $event->[CHAN], $event->[3], $event->[4]
          if $fh;
    } elsif ( $event->[0] eq 'set_tempo' ) {
        # NOTE this changes the tempo in this code as that's where the
        # time control is done. May need a flag to ignore this?
        warn "set_tempo $event->[2]\n";
        $options{s} = $event->[2];
        $dtime2usec = $ticks / $options{s};
    } else {
        say "$track_number\tTODO\t", Dumper($event);
    }
}