#!perl
#
# ly-fu - compile and run LilyPond snippets specified on command line:
#
#   ly-fu c des ees c des bes c aes
#
# NOTE set the MIDI_EDITOR and SCORE_VIEWER environment variables to
# programs that can play *.midi files and open *.pdf on your system.
#
# Run perldoc(1) on this file for additional documentation.
#
# A ZSH completion script is available in the zsh-compdef/ directory of the
# App::MusicTools distribution.

use 5.14.0;
use warnings;
use Capture::Tiny 'capture';
use File::Spec::Functions 'tmpdir';
use File::Temp   ();
use Getopt::Long qw(GetOptions :config no_ignore_case);
use POSIX 'setsid';
use Text::ParseWords 'shellwords';

my @Lilypond_Cmd = qw/lilypond -dno-point-and-click/;
# these can be found under https://thrig.me/src/scripts.git
my @MIDI_Player  = qw/midiplay/;
my @Score_Reader = qw/mopen/;

# as found in OpenBSD 7.3
my $LILYPOND_VERSION = '2.22.2';

my $instrument = "acoustic grand";
my $partial    = q{};
my $relative   = q{c'};
my $repeats    = 1;
my $staff      = 'Staff';
my $tempo      = 130;

########################################################################
#
# MAIN

@Lilypond_Cmd = shellwords( $ENV{LILYPOND} ) if exists $ENV{LILYPOND};
@MIDI_Player  = shellwords( $ENV{MIDI_EDITOR} )
  if exists $ENV{MIDI_EDITOR};
@Score_Reader = shellwords( $ENV{SCORE_VIEWER} )
  if exists $ENV{SCORE_VIEWER};

GetOptions(
    'absolute|abs|a'   => \my $is_absolute,
    'articulate'       => \my $do_articulate,
    'events'           => \my $do_events,
    'help|h|?'         => \&emit_help,
    'instrument|i=s'   => \$instrument,
    'language=s'       => \my $language,
    'layout|l'         => \my $Flag_Generate_Score,
    'open'             => \my $Flag_Show_Score,
    'partial|p=s'      => \$partial,
    'relative|b=s'     => \$relative,
    'repeats|r=i'      => \$repeats,
    'rhythmic-staff|R' => sub { $staff = 'RhythmicStaff' },
    'show-code'        => \my $Flag_Show_Code,
    'silent|s'         => \my $Flag_No_MIDI,
    'sleep|S=i'        => \my $Flag_Sleep_Kluge,
    'tempo|t=s'        => \$tempo,
    'verbose'          => \my $Flag_Verbose,
) or exit 64;

undef $relative if $is_absolute;

$Flag_Generate_Score = 1 if $Flag_Show_Score;
push @Lilypond_Cmd, '--pdf' if $Flag_Generate_Score;

$Flag_Sleep_Kluge ||= 0;

$language //= 'nederlands';

if ( !@ARGV or ( @ARGV == 1 and $ARGV[0] eq '-' ) ) {
    @ARGV = <STDIN>;
}

my $pre_art  = '';
my $post_art = '';
if ($do_articulate) {
    $pre_art  = '\articulate {';
    $post_art = '}';
}
my $event_listener = $do_events ? '\include "event-listener.ly"' : "";

my $rel_str = defined $relative ? '\relative ' . $relative : '';

my ( $repeat_midi, $repeat_score, $score_bar );
if ( $repeats > 1 ) {
    $repeat_midi  = "\\repeat unfold $repeats ";
    $repeat_score = "\\repeat volta $repeats ";
    $score_bar    = '';
} else {
    $repeat_midi  = '';
    $repeat_score = '';
    $score_bar    = qq{\\bar "|."};
}

if ($Flag_Show_Code) {
    $Flag_Generate_Score = 1;
    $Flag_No_MIDI        = 0;
}

my $lilypond_input = <<"END_TMPL_HEAD";
\\version "$LILYPOND_VERSION"
\\include "articulate.ly"
\\language "$language"
$event_listener
\\header {
  title = "ly-fu generated output"
}
themusic = $rel_str {
  @ARGV
}
END_TMPL_HEAD

if ($Flag_Generate_Score) {
    $lilypond_input .= <<"END_TMPL_LAYOUT";
\\score {
  \\new $staff << $rel_str {
    \\accidentalStyle "neo-modern"
    \\set Staff.instrumentName = #"$instrument"
    \\tempo 4=$tempo
    $partial
    $repeat_score { \\themusic }
    $score_bar
  } >>
  \\layout { }
}
END_TMPL_LAYOUT
}

unless ($Flag_No_MIDI) {
    $lilypond_input .= <<"END_TMPL_MIDI";
\\score {
  \\new Staff << $rel_str {
    \\set Staff.midiInstrument = #"$instrument"
    $pre_art
    \\tempo 4=$tempo
    $partial
    $repeat_midi { \\themusic }
    $post_art
  } >>
  \\midi { }
}
END_TMPL_MIDI
}

if ($Flag_Show_Code) {
    print $lilypond_input;
    exit 0;
}

my $work_dir = tmpdir();
chdir $work_dir or die "ly-fu: chdir failed '$work_dir': $!\n";

# NOTE a dedicated directory for output would be safer with a shared tmp
# dir, as I am unsure of how well lilypond defends against /tmp attacks.
# (I use a TMPDIR that is not the system one.)
my $tmp_ly = File::Temp->new(
    DIR      => $work_dir,
    SUFFIX   => '.ly',
    TEMPLATE => 'ly-fu.XXXXXXXXXX',
    UNLINK   => 0,
);
$tmp_ly->print($lilypond_input);
$tmp_ly->flush;
$tmp_ly->sync;

my $ly_filename = $tmp_ly->filename;
( my $score_filename = $ly_filename ) =~ s/\.ly/\.pdf/;
( my $midi_filename  = $ly_filename ) =~ s/\.ly/\.midi/;
close $tmp_ly;

say $ly_filename if $Flag_Generate_Score;

# lilypond is noisy by default
my ( $stdout, $stderr, $status ) = capture {
    system @Lilypond_Cmd, $ly_filename;
};
if ( $status != 0 ) {
    print STDERR $stderr;
    exit 1;
}
print STDERR $stderr if $Flag_Verbose;

if ($Flag_Show_Score) {
    local $SIG{CHLD} = 'IGNORE';
    my $pid = fork();
    die "ly-fu: could not fork score reader: $!\n" unless defined $pid;
    unless ($pid) {
        # a vague effort at daemonization; may be necessary if the PDF
        # view being open holds ly-fu open which then prevents a text
        # editor from moving on
        open( STDIN,  '<', '/dev/null' );
        open( STDOUT, '>', '/dev/null' );
        ( setsid() == -1 ) and die "ly-fu: setsid failed: $!\n";
        open( STDERR, '>&', STDOUT );
        exec( @Score_Reader, $score_filename )
          or die "ly-fu: could not exec score reader '@Score_Reader': $!\n";
    }
}

unless ($Flag_No_MIDI) {
    if ($Flag_Generate_Score) {
        # we're keeping the files, so there's nothing more this script
        # needs to do
        exec( @MIDI_Player, $midi_filename )
          or die
          "ly-fu: could not exec MIDI player '@MIDI_Player $midi_filename': $!\n";
    } else {
        system( @MIDI_Player, $midi_filename ) == 0
          or die
          "ly-fu: could not run MIDI player '@MIDI_Player $midi_filename': $!\n";
    }
}

if ($Flag_Generate_Score) {
    # the files might be shared, so open up the permissions
    chmod 0644, $ly_filename, $midi_filename, $score_filename;
} else {
    # Preview.app got increasingly slower to actually start over time,
    # hence this kluge. Eventually I switched to mupdf, and eventually
    # the 2009 macbook died in late 2022.
    sleep $Flag_Sleep_Kluge if $Flag_Sleep_Kluge > 0;
    unlink $ly_filename, $midi_filename, $score_filename;
}

########################################################################
#
# SUBROUTINES

sub emit_help {
    warn <<"END_HELP";
Usage: $0 [options] "lilypond code ..."

The lilypond input will likely need to be quoted as lilypond input may
clash with various shell metacharacters.

  --absolute      Notation assumed to be absolute.
  --instrument    Set MIDI instrument (see lilypond docs).
  --partial|p     Lilypond fragment played once at beginning.
  --relative|b    Specify what note input is relative to.
  --repeats|r     How many times to repeat the lilypond input.
  --tempo|t       Set a tempo for the music.

  --layout|l      Save the MIDI and other various files.
  --open          Show the score in some sort of viewer.
  --show-code     Print the lilypond data to stdout and exit.
  --silent|s      Skip generating MIDI.
  --sleep|S       Sleep before unlink of various tmp files.
  --verbose       Show noise from lilypond, MIDI player.

END_HELP
    exit 64;
}
__END__

=head1 NAME

ly-fu - play or display lilypond snippets

=head1 SYNOPSIS

Set the MIDI_EDITOR and SCORE_VIEWER environment variables to
suitable MIDI playing and PDF opening utilities, or adjust the source
to your liking.

  $ export MIDI_EDITOR=timidity
  $ export SCORE_VIEWER=xpdf
  $ ly-fu --instrument=banjo c des ees c des bes c aes
  $ ly-fu  -i=trumpet --open "c8 g'4 c,8 g'4 c,8 g'2"
  $ echo c e g | ly-fu -

Or, to instead save the generated lilypond somewhere:

  $ ly-fu --show-code e e e c1 > masterpiece.ly

=head1 DESCRIPTION

Plays and possibly displays lilypond snippets entered at the command
line. The C<MIDI_EDITOR> environment variable should be set to a program
that can play MIDI files, and the C<SCORE_VIEWER> optionally set to a
PDF viewer. (Or edit the source code as necessary.)

L<https://www.lilypond.org/> and in particular the Learning and Notation
manuals should be consulted to understand the lilypond syntax.

=head1 OPTIONS

This program currently supports the following command line switches:

=over 4

=item B<--absolute>

Assume lilypond absolute notation.

=item B<--instrument>=I<instrument>

Set MIDI instrument (see lilypond docs and ZSH compdef script).

=item B<--language>=I<language>

Specify the lilypond language to use, by default the C<lilypond> default
of C<nederlands>. If using note names from L<Music::PitchNum::German>,

  ... | ly-fu --language=deutsch ...

may be of some use. "Note names in other languages" in the lilypond
notation reference contains the full list of supported languages.

=item B<--layout>

Save the MIDI and other various files (they are unlinked by default).

=item B<--open>

Show the score via the C<SCORE_VIEWER> program.

=item B<--partial>=I<lilypond fragment>

A lilypond fragment played once at the beginning.

=item B<--relative>=I<note>

Specify what note the input is relative to.

=item B<--repeats>=I<count>

How many times to repeat the (non-B<partial>) input.

=item B<--rhythmic-staff>

Make the staff a lilypond C<RhythmicStaff> instead of the usual one.

=item B<--show-code>

Prints the generated lilypond data to standard out, then exits the
program. The score is not shown, nor the music played.

=item B<--silent>

Do not play the MIDI.

=item B<--sleep>=I<seconds>

Kluge sleep before unlinking temporary files (might be handy if
C<SCORE_VIEWER> is slow to start).

=item B<--tempo>=I<tempo>

What the tempo is (in quarter notes, e.g. C<120> or the like).

=item B<--verbose>

Show output from lilypond and the MIDI player.

=back

=head1 FILES

NOTE This program makes heavy use of temporary files. On a shared
system, the lilypond generated output files might introduce /tmp
security problems (arbitrary file clobber or unlink against the user
running the code). These can be avoided by employing a private temporary
directory, for example by pointing the C<TMPDIR> environment variable to
such a directory.

A ZSH completion script is available in the C<zsh-compdef/> directory of
the L<App::MusicTools> distribution. Install this to a C<$fpath>
directory.

=head1 SEE ALSO

L<https://www.lilypond.org/>

=head1 COPYRIGHT

Copyright 2012 Jeremy Mates

This program is distributed under the (Revised) BSD License:
L<https://opensource.org/license/bsd-3-clause/>

=cut
