Improved Autocall Script

I’ve updated the Autocall Ruby script I’ve mentioned various times before by porting it to Zsh and adding a TON of features. The following posts are now obsolete:

Autocall: A Script to Watch and “Call” Programs on a Changed Target Source File
Updated Autolily Script
Auto-update/make/compile script for Lilypond: Autolily

If you haven’t read my previous posts on this topic, basically, Autocall is a script that I wrote to automate executing an arbitrary command (from the shell) any time some file is modified. In practice, it is very useful for any small/medium project that needs to compile/build something out of source code. Typical use cases are for LilyPond files, LaTeX/XeTeX, and C/C++ source code.

Anyway, the script is now very robust and much more intelligent. It now parses options and flags instead of stupidly looking at ARGV one at a time. Perhaps the best new feature is the ability to kill processes that hang. Also, you can now specify up to 9 different commands with 9 instances of the “-c” flag (additional instances are ignored). Commands 2-9 are accessible manually via the numeric keys 2-9 (command 1 is the default). This is useful if you have, for instance, different build targets in a makefile. E.g., you could do

$ autocall -c "make" -c "make -B all" -c "make clean" -l file_list

to make things a bit easier.

I use this script all the time — mostly when working with XeTeX files or compiling source code. It works best in a situation where you have to do something X whenever files/directories Y changes in any way. Again, the command to be executed is arbitrary, so you could use it to call some script X whenever a change is detected in a file/directory. If you use it with LaTeX/XeTeX/TeX, use the “-halt-on-error” option so that you don’t have to have autocall kill it (only available with the -k flag). The copious comments should help you get started. Like all my stuff, it is not licensed at all — it’s released into the PUBLIC DOMAIN, without ANY warranties whatsoever in any jurisdiction (use at your own risk!).

#!/bin/zsh
# PROGRAM: autocall
# AUTHOR: Shinobu (https://zuttobenkyou.wordpress.com)
# LICENSE: PUBLIC DOMAIN
#
#
# DESCRIPTION:
#
# Autocall watches (1) a single file, (2) directory, and/or (3) a text file
# containing a list of files/directories, and if the watched files and/or
# directories become modified, runs the (first) given command string. Multiple
# commands can be provided (a total of 9 command strings are recognized) to
# manually execute different commands.
#
#
# USAGE:
#
# See msg("help") function below -- read that portion first!
#
#
# USER INTERACTION:
#
# Press "h" for help.
# Pressing a SPACE, ENTER, or "1" key forces execution of COMMAND immediately.
# Keys 2-9 are hotkeys to extra commands, if there are any.
# Press "c" for the command list.
# To exit autocall gracefully, press "q".
#
#
# DEFAULT SETTINGS:
#
# (-w) DELAY    = 5
# (-x) FACTOR   = 4
#
#
# EXAMPLES:
#
# Execute "pdflatex -halt-on-error report.tex" every time "report.tex" or "ch1.tex" is
# modified (if line count changes in either file; modification checked every 5
# seconds by default):
#    autocall -c "pdflatex -halt-on-error report.tex" -F report.tex -f ch1.tex
#
# Same, but only look at "ch1.tex" (useful, assuming that report.tex includes
# ch1.tex), and automatically execute every 4 seconds:
#    autocall -c "pdflatex -halt-on-error report.tex" -F ch1.tex -w 1 -x 4
#       (-x 0 or -x 1 here would also work)
#
# Same, but also automatically execute every 20 (5 * 4) seconds:
#    autocall -c "pdflatex -halt-on-error report.tex" -F ch1.tex -x 4
#
# Same, but automatically execute every 5 (5 * 1) seconds (-w is 5 by default):
#    autocall -c "pdflatex -halt-on-error report.tex" -F ch1.tex -x 1
#
# Same, but automatically execute every 1 (1 * 1) second:
#    autocall -c "pdflatex -halt-on-error report.tex" -F ch1.tex -w 1 -x 1
#
# Same, but automatically execute every 17 (1 * 17) seconds:
#    autocall -c "pdflatex -halt-on-error report.tex" -F ch1.tex -w 1 -x 17
#
# Same, but for "ch1.tex", watch its byte size, not line count:
#    autocall -c "pdflatex -halt-on-error report.tex" -b ch1.tex -w 1 -x 17
#
# Same, but for "ch1.tex", watch its timestamp instead (i.e., every time
# this file is saved, the modification timestamp will be different):
#    autocall -c "pdflatex -halt-on-error report.tex" -f ch1.tex -w 1 -x 17
#
# Same, but also look at the contents of directory "images/ocean":
#    autocall -c "pdflatex -halt-on-error report.tex" -f ch1.tex -d images/ocean -w 1 -x 17
#
# Same, but also look at the contents of directory "other" recursively:
#    autocall -c "pdflatex -halt-on-error report.tex" -f ch1.tex -d images/ocean -D other -w 1 -x 17
#
# Same, but look at all files and/or directories (recursively) listed in file
# "watchlist" instead:
#    autocall -c "pdflatex -halt-on-error report.tex" -l watchlist -w 1 -x 17
#
# Same, but also look at "newfile.tex":
#    autocall -c "pdflatex -halt-on-error report.tex" -l watchlist -f newfile.tex -w 1 -x 17
#
# Same, but also allow manual execution of "make clean" with hotkey "2":
#    autocall -c "pdflatex -halt-on-error report.tex" -c "make clean" -l watchlist -f newfile.tex -w 1 -x 17
#
###############################################################################
###############################################################################

#-----------------#
# Local functions #
#-----------------#

msg () {
    case $1 in
        "help")
echo "
autocall: Usage:

autocall [OPTIONS]

Required parameter:
-c COMMAND      The command to be executed (put COMMAND in quotes). Note that
                COMMAND can be a set of multiple commands, e.g. \"make clean;
                make\". You can also specify multiple commands by invoking
                -c COMMAND multiple times -- the first 9 of these are set to
                hotkeys 1 through 9, if present. This is useful if you want to
                have a separate command that is available and can only be
                executed manually.

One or more required parameters (but see -x below):
-f FILE         File to be watched. Modification detected by time.
-F FILE         File to be watched. Modification detected by line-size.
-b FILE         File to be watched. Modification detected by bytes.
-d DIRECTORY    Directory to be watched. Modification detected by time.
-D DIRECTORY    Directory to be watched, recursively. Modification
                detected by time.
-l FILE         Text file containing a list of files/directories (each on
                its own line) to be watched (directories listed here are
                watched recursively). Modification is detected with 'ls'.

Optional parameters:
-w DELAY        Wait DELAY seconds before checking on the watched
                files/directories for modification; default 5.
-t TIMEOUT      If COMMAND does not finish execution after TIMEOUT seconds,
                send a SIGTERM signal to it (but do nothing else afterwards).
-k KDELAY       If COMMAND does not finish execution after TIMEOUT,
                then wait KDELAY seconds and send SIGKILL to it if COMMAND is
                still running. If only -k is given without -t, then -t is
                automatically set to the same value as TIMEOUT.
-x FACTOR       Automatically execute the command repeatedly every DELAY *
                FACTOR seconds, regardless of whether the watched
                files/directories were modified. If FACTOR is zero, it is set
                to 1. If -x is set, then -f, -d, and -l are not required (i.e.,
                if only the -c and -x options are specified, autocall will
                simply act as a while loop executing COMMAND every 20 (or
                more if FACTOR is greater than 1) seconds). Since the
                formula is (DELAY * FACTOR) seconds, if DELAY is 1,
                FACTOR's face value itself, if greater than 0, is the seconds
                amount.
-a              Same as \`-x 1'
-h              Show this page and exit (regardless of other parameters).
-v              Show version number and exit (regardless of other parameters).
"
            exit 0
            ;;
        "version")
            echo "autocall version 1.0"
            exit 0
            ;;
        *)
            echo "autocall: $1"
            exit 1
            ;;
    esac
}

is_number () {
    if [[ $(echo $1 | sed 's/^[0-9]\+//' | wc -c) -eq 1 ]]; then
        true
    else
        false
    fi
}

autocall_exec () {
    timeout=$2
    killdelay=$3
    col=""
    case $4 in
        1) col=$c1 ;;
        2) col=$c2 ;;
        3) col=$c3 ;;
        4) col=$c4 ;;
        5) col=$c5 ;;
        6) col=$c6 ;;
        *) col=$c1 ;;
    esac
    echo "\nautocall:$c2 [$(date --rfc-3339=ns)]$ce$col $5$ce"
    if [[ $# -eq 7 ]]; then
        diff -u0 -B -d <(echo "$6") <(echo "$7") | tail -n +4 | sed -e "/^[@-].\+/d" -e "s/\(\S\+\s\+\S\+\s\+\S\+\s\+\S\+\s\+\)\(\S\+\s\+\)\(\S\+\s\+\S\+\s\+\S\+\s\+\)/\1$c1\2$ce$c2\3$ce/" -e "s/^/  $c1>$ce /"
        echo
    fi
    echo "autocall: calling command \`$c4$1$ce'..."
    # see the "z" flag under PARAMTER EXPANSION under "man ZSHEXPN" for more info
    if [[ $tflag == true || $kflag == true ]]; then
        # the 'timeout' command gives nice exit statuses -- it gives 124 if
        # command times out, but if the command exits with an error of its own,
        # it gives that error number (so if the command doesn't time out, but
        # exits with 4 or 255 or whatever, it (the timeout command) will exit
        # with that number instead)

        # note: if kflag is true, then tflag is always true
        com_exit_status=0
        if [[ $kflag == true ]]; then
            eval timeout -k $killdelay $timeout $1 2>&1 | sed "s/^/  $col>$ce /"
            com_exit_status=$pipestatus[1]
        else
            eval timeout $timeout $1 2>&1 | sed "s/^/  $col>$ce /"
            com_exit_status=$pipestatus[1]
        fi
        if [[ $com_exit_status -eq 124 ]]; then
            echo "\n${c6}autocall: command timed out$ce"
        elif [[ $com_exit_status -ne 0 ]]; then
            echo "\n${c6}autocall: command exited with error status $com_exit_status$ce"
        else
            echo "\n${c1}autocall: command executed successfully$ce"
        fi
    else
        eval $1 2>&1 | sed "s/^/  $col>$ce /"
        com_exit_status=$pipestatus[1]
        if [[ $com_exit_status -ne 0 ]]; then
            echo "\n${c6}autocall: command exited with error status $com_exit_status$ce"
        else
            echo "\n${c1}autocall: command executed successfully$ce"
        fi
    fi
}

#------------------#
# Global variables #
#------------------#

# colors
c1="\x1b[1;32m" # bright green
c2="\x1b[1;33m" # bright yellow
c3="\x1b[1;34m" # bright blue
c4="\x1b[1;36m" # bright cyan
c5="\x1b[1;35m" # bright purple
c6="\x1b[1;31m" # bright red
ce="\x1b[0m"

coms=()
delay=5
xdelay_factor=4
f=()
F=()
b=()
d=()
D=()
l=()
l_targets=()
wflag=false
xflag=false
tflag=false
kflag=false
timeout=0
killdelay=0

tstampf="" # used to DISPLAY modification only for -f flag
linestamp="" # used to DETECT modification only for -f flag
tstampF="" # used to detect AND display modifications for -F flag
tstampb="" # used to DISPLAY modification only for -b flag
bytestamp="" # used to DETECT modification only for -b flag
tstampd="" # used to detect AND display modifications for -d flag
tstampD="" # used to detect AND display modifications for -D flag
tstampl="" # used to detect AND display modifications for -l flag

tstampf_new=""
linestamp_new=""
tstampF_new=""
tstampb_new=""
bytestamp_new=""
tstampd_new=""
tstampD_new=""
tstampl_new=""

#----------------#
# PROGRAM START! #
#----------------#

#---------------#
# Parse options #
#---------------#

# the leading ":" in the opstring silences getopts's own error messages;
# the colon after a single letter indicates that that letter requires an
# argument

# first parse for the presence of any -h and -v flags (while silently ignoring
# the other recognized options)
while getopts ":c:w:f:F:b:d:D:l:t:k:x:ahv" opt; do
    case "$opt" in
    h)  msg "help" ;;
    v)  msg "version" ;;
    *) ;;
    esac
done
# re-parse from the beginning again if there were no -h or -v flags
OPTIND=1
while getopts ":c:w:f:F:b:d:D:l:t:k:x:a" opt; do
    case "$opt" in
    c)
        com_binary=$(echo "$OPTARG" | sed 's/ \+/ /g' | sed 's/;/ /g' | cut -d " " -f1)
        if [[ $(which $com_binary) == "$com_binary not found" ]]; then
            msg "invalid command \`$com_binary'"
        else
            coms+=("$OPTARG")
        fi
        ;;
    w)
        if $(is_number "$OPTARG"); then
            if [[ $OPTARG -gt 0 ]]; then
                wflag=true
                delay=$OPTARG
            else
                msg "DELAY must be greater than 0"
            fi
        else
            msg "invalid DELAY \`$OPTARG'"
        fi
        ;;
    f)
        if [[ ! -f "$OPTARG" ]]; then
            msg "file \`$OPTARG' does not exist"
        else
            f+=("$OPTARG")
        fi
        ;;
    F)
        if [[ ! -f "$OPTARG" ]]; then
            msg "file \`$OPTARG' does not exist"
        else
            F+=("$OPTARG")
        fi
        ;;
    b)
        if [[ ! -f "$OPTARG" ]]; then
            msg "file \`$OPTARG' does not exist"
        else
            b+=("$OPTARG")
        fi
        ;;
    d)
        if [[ ! -d "$OPTARG" ]]; then
            msg "directory \`$OPTARG' does not exist"
        else
            d+=("$OPTARG")
        fi
        ;;
    D)
        if [[ ! -d "$OPTARG" ]]; then
            msg "directory \`$OPTARG' does not exist"
        else
            D+=("$OPTARG")
        fi
        ;;
    l)
        if [[ ! -f $OPTARG ]]; then
            msg "file \`$OPTARG' does not exist"
        else
            l+=("$OPTARG")
        fi
        ;;
    t)
        tflag=true
        if $(is_number "$OPTARG"); then
            if [[ $OPTARG -gt 0 ]]; then
                timeout=$OPTARG
            else
                msg "TIMEOUT must be greater than 0"
            fi
        else
            msg "invalid TIMEOUT \`$OPTARG'"
        fi
        ;;
    k)
        kflag=true
        if $(is_number "$OPTARG"); then
            if [[ $OPTARG -gt 0 ]]; then
                killdelay=$OPTARG
            else
                msg "TIMEOUT must be greater than 0"
            fi
        else
            msg "invalid KDELAY \`$OPTARG'"
        fi
        ;;
    x)
        xflag=true
        if $(is_number "$OPTARG"); then
            if [[ $OPTARG -gt 0 ]]; then
                xdelay_factor=$OPTARG
            elif [[ $OPTARG -eq 0 ]]; then
                xdelay_factor=1
            else
                msg "invalid FACTOR \`$OPTARG'"
            fi
        fi
        ;;
    a) xflag=true ;;
    🙂
        msg "missing argument for option \`$OPTARG'"
        ;;
    *)
        msg "unrecognized option \`$OPTARG'"
        ;;
    esac
done

#-----------------#
# Set misc values #
#-----------------#

if [[ $kflag == true && $tflag == false ]]; then
    tflag=true
    timeout=$killdelay
fi

#------------------#
# Check for errors #
#------------------#

# check that the given options are in good working order
if [[ -z $coms[1] ]]; then
    msg "help"
elif [[ (-n $f && -n $d && -n $D && -n $l) && $xflag == false ]]; then
    echo "autocall: see help with -h"
    msg "at least one or more of the (1) -f, -d, -D, or -l paramters, or (2) the -x parameter, required"
fi

#-------------------------------#
# Record state of watched files #
#-------------------------------#

if [[ -n $F ]]; then
    if [[ $#F -eq 1 ]]; then
        linestamp=$(wc -l $F)
    else
        linestamp=$(wc -l $F | head -n -1) # remove the last "total" line
    fi
    tstampF=$(ls --full-time $F)
fi
if [[ -n $f ]]; then
    tstampf=$(ls --full-time $f)
fi
if [[ -n $b ]]; then
    if [[ $#b -eq 1 ]]; then
        bytestamp=$(wc -c $b)
    else
        bytestamp=$(wc -c $b | head -n -1) # remove the last "total" line
    fi
    tstampb=$(ls --full-time $b)
fi
if [[ -n $d ]]; then
    tstampd=$(ls --full-time $d)
fi
if [[ -n $D ]]; then
    tstampD=$(ls --full-time -R $D)
fi
if [[ -n $l ]]; then
    for listfile in $l; do
        if [[ ! -f $listfile ]]; then
            msg "file \`$listfile ' does not exist"
        else
            while read line; do
                if [[ ! -e "$line" ]]; then
                    msg "\`$listfile': file/path \`$line' does not exist"
                else
                    l_targets+=("$line")
                fi
            done < $listfile # read contents of $listfile!
        fi
    done
    tstampl=$(ls --full-time -R $l_targets)
fi

#----------------------#
# Begin execution loop #
#----------------------#
# This is like Russian Roulette (where "firing" is executing the command),
# except that all the chambers are loaded, and that on every new turn, instead
# of picking the chamber randomly, we look at the very next chamber. After
# every chamber is given a turn, we reload the gun and start over.
#
# If we detect file/directory modification, we pull the trigger. We can also
# pull the trigger by pressing SPACE or ENTER. If the -x option is provided,
# the last chamber will be set to "always shoot" and will always fire (if the
# trigger hasn't been pulled by the above methods yet).

if [[ $xflag == true && $xdelay_factor -le 1 ]]; then
    xdelay_factor=1
fi
com_num=1
for c in $coms; do
    echo "autocall: command slot $com_num set to \`$c4$coms[$com_num]$ce'"
    let com_num+=1
done
echo "autocall: press keys 1-$#coms to execute a specific command"
if [[ $wflag == true ]]; then
    echo "autocall: modification check interval set to $delay sec"
else
    echo "autocall: modification check interval set to $delay sec (default)"
fi
if [[ $xflag == true ]]; then
    echo "autocall: auto-execution interval set to ($delay * $xdelay_factor) = $(($delay*$xdelay_factor)) sec"
fi
if [[ $tflag == true ]]; then
    echo "autocall: TIMEOUT set to $timeout"
    if [[ $kflag == true ]]; then
        echo "autocall: KDELAY set to $killdelay"
    fi
fi
echo "autocall: press ENTER or SPACE to execute manually"
echo "autocall: press \`c' for command list"
echo "autocall: press \`h' for help"
echo "autocall: press \`q' to quit"
key=""
while true; do
    for i in {1..$xdelay_factor}; do
        #------------------------------------------#
        # Case 1: the user forces manual execution #
        #------------------------------------------#
        # read a single key from the user
        read -s -t $delay -k key
        case $key in
            # note the special notation $'\n' to detect an ENTER key
            $'\n'|" "|1)
                autocall_exec $coms[1] $timeout $killdelay 4 "manual execution"
                key=""
                continue
                ;;
            2|3|4|5|6|7|8|9)
                if [[ -n $coms[$key] ]]; then
                    autocall_exec $coms[$key] $timeout $killdelay 4 "manual execution"
                    key=""
                    continue
                else
                    echo "autocall: command slot $key is not set"
                    key=""
                    continue
                fi
                ;;
            c)
                com_num=1
                echo ""
                for c in $coms; do
                    echo "autocall: command slot $com_num set to \`$c4$coms[$com_num]$ce'"
                    let com_num+=1
                done
                key=""
                continue
                ;;
            h)
                echo "\nautocall: press \`c' for command list"
                echo "autocall: press \`h' for help"
                echo "autocall: press \`q' to exit"
                com_num=1
                for c in $coms; do
                    echo "autocall: command slot $com_num set to \`$c4$coms[$com_num]$ce'"
                    let com_num+=1
                done
                echo "autocall: press keys 1-$#coms to execute a specific command"
                echo "autocall: press ENTER or SPACE or \`1' to execute first command manually"
                key=""
                continue
                ;;
            q)
                echo "\nautocall: exiting..."
                exit 0
                ;;
            *) ;;
        esac

        #------------------------------------------------------------------#
        # Case 2: modification is detected among watched files/directories #
        #------------------------------------------------------------------#
        if [[ -n $f ]]; then
            tstampf_new=$(ls --full-time $f)
        fi
        if [[ -n $F ]]; then
            if [[ $#F -eq 1 ]]; then
                linestamp_new=$(wc -l $F)
            else
                linestamp_new=$(wc -l $F | head -n -1) # remove the last "total" line
            fi
            tstampF_new=$(ls --full-time $F)
        fi
        if [[ -n $b ]]; then
            if [[ $#b -eq 1 ]]; then
                bytestamp_new=$(wc -c $b)
            else
                bytestamp_new=$(wc -c $b | head -n -1) # remove the last "total" line
            fi
            tstampb_new=$(ls --full-time $b)
        fi
        if [[ -n $d ]]; then
            tstampd_new=$(ls --full-time $d)
        fi
        if [[ -n $D ]]; then
            tstampD_new=$(ls --full-time -R $D)
        fi
        if [[ -n $l ]]; then
            tstampl_new=$(ls --full-time -R $l_targets)
        fi
        if [[ -n $f && "$tstampf" != "$tstampf_new" ]]; then
            autocall_exec $coms[1] $timeout $killdelay 1 "change detected" "$tstampf" "$tstampf_new"
            tstampf=$tstampf_new
            continue
        elif [[ -n $F && "$linestamp" != "$linestamp_new" ]]; then
            autocall_exec $coms[1] $timeout $killdelay 1 "change detected" "$tstampF" "$tstampF_new"
            linestamp=$linestamp_new
            tstampF=$tstampF_new
            continue
        elif [[ -n $b && "$bytestamp" != "$bytestamp_new" ]]; then
            autocall_exec $coms[1] $timeout $killdelay 1 "change detected" "$tstampb" "$tstampb_new"
            bytestamp=$bytestamp_new
            tstampb=$tstampb_new
            continue
        elif [[ -n $d && "$tstampd" != "$tstampd_new" ]]; then
            autocall_exec $coms[1] $timeout $killdelay 1 "change detected" "$tstampd" "$tstampd_new"
            tstampd=$tstampd_new
            continue
        elif [[ -n $D && "$tstampD" != "$tstampD_new" ]]; then
            autocall_exec $coms[1] $timeout $killdelay 1 "change detected" "$tstampD" "$tstampD_new"
            tstampD=$tstampD_new
            continue
        elif [[ -n $l && "$tstampl" != "$tstampl_new" ]]; then
            autocall_exec $coms[1] $timeout $killdelay 1 "change detected" "$tstampl" "$tstampl_new"
            tstampl=$tstampl_new
            continue
        fi

        #-----------------------------------------------------#
        # Case 3: periodic, automatic execution was requested #
        #-----------------------------------------------------#
        if [[ $xflag == true && $i -eq $xdelay_factor ]]; then
            autocall_exec $coms[1] $timeout $killdelay 3 "commencing auto-execution ($(($delay*$xdelay_factor)) sec)"
        fi
    done
done

# vim:syntax=zsh
Advertisements

Updated Autolily Script

UPDATE July 24, 2010: This post is now totally obsolete. See this post instead.

This is an update to my previous post. Now, Autolily’s single argument is no longer required to be the bare *.ly filename itself, but instead may optionally include a path! See the embedded comments in the source code below. Enjoy!


 1 #!/usr/bin/ruby
 2 #===============================================================================================================#
 3 # Program name: Autolily                                                                                        #
 4 # LICENSE: PUBLIC DOMAIN                                                                                        #
 5 # This program takes 1 argument, the name of a lilypond file (*.ly), and watches it for changes every 1 second. #
 6 # If there has been any change, it simply calls lilypond on it to create a new .pdf/.ps/.midi of it.            #
 7 #                                                                                                               #
 8 # Place this script somewhere, like in ~/scripts                                                                #
 9 # Then, open up a terminal and call it like so: ~/scripts/autolily.rb [file]                                    #
10 # [file] must be a LilyPond file (.ly), but it can be located anywhere -- i.e., you may include paths in your   #
11 # file, such as "~/sheet-music/classical/bach2.ly" or "../../bach3.ly".                                         #
12 #                                                                                                               #
13 # You might want to do a "sudo ln -s" of autolily.rb to one of your system's $PATH directories (e.g., /usr/bin) #
14 # to avoid typing out the path to autolily.rb every time you use it. Continuing the example from above,         #
15 # something like "sudo ln -s ~/scripts/autolily.rb /usr/bin/autolily" should do (make sure that                 #
16 # /usr/bin/autolily does not exist already, as the above comman will overwrite that file if it exists).         #
17 #                                                                                                               #
18 # Now you can just do:                                                                                          #
19 #                                                                                                               #
20 #     autolily [file]                                                                                           #
21 #                                                                                                               #
22 # from anywhere in your system!                                                                                 #
23 #                                                                                                               #
24 # To exit, press CTRL-C.                                                                                        #
25 #===============================================================================================================#
26
27 if ARGV.size > 0
28     file_data_orig = ""
29     file = ARGV.shift
30     pathsize = file.split("/").size
31     ls_call = "ls --full-time"
32
33     # make sure that the "file" variable is a filename, and not mixed with its path
34     if pathsize > 1
35         path_to_file = file.split("/").first(pathsize - 1).join("/")
36         file = file.split("/").last
37         ls_call << " #{path_to_file}" # modify our `ls` command to reflect relative location of file
38     end
39
40     `#{ls_call}`.split("\n").each do |line|
41         if line.split(/\s/).last == file
42             file_data_orig = line
43             break
44         end
45     end
46     file_data_new = ""
47
48     # enter infinite loop -- keep compiling the given lilypond file if it has changed in the past 1 second
49     while true
50         # detect the file size and also timestamp
51         lsarr = `#{ls_call}`.split("\n")
52         lsarr.shift # get rid of the first line, since that is the size of all the files in the directory
53
54         # find our file from ls's output!
55         lsarr.each do |line|
56             if line.split(/\s/).last == file
57                 file_data_new = line
58                 break
59             end
60         end
61
62         # if there is any change detected, run lilypond on it
63         if file_data_orig != file_data_new
64             puts "\n\e[1;4;38;5;226mAutolily: Change detected in given file; invoking lilypond...\e[0m\n"
65             if pathsize > 1
66             `lilypond "#{path_to_file}/#{file}"`
67             else
68             `lilypond "#{file}"`
69             end
70             file_data_orig = file_data_new
71         end
72         sleep 1
73     end
74 else
75     puts "No .ly file specified.\n"
76 end

Auto-update/make/compile script for Lilypond: Autolily

UPDATE July 24, 2010: This post is now totally obsolete. See this post instead.

I’ve so far used Lilypond twice to make nice, clean music of some classical pieces. If you don’t know what Lilypond is already, it is basically LaTeX, but for music. Since the music notation style is almost always the same across many different genres, LaTeX’s “what you see is what you mean” philosophy applies particularly well to your sheetmusic-making needs.

The basic way you work is — create an .ly file pursuant to the lilypond format, then save it, then call “lilypond [filename]” on it from the terminal, which will generate a .pdf, .ps, and .midi output all for you (that is, if you have a “\layout {}” and also a “\midi {}” in you “\score {}”). If you have dual monitors, then you can open up the .pdf file with evince, and then evince will automatically update to the latest .pdf version by itself (no need to close and re-open the same generated .pdf file).

This workflow is nice, but it could be better. For one, it takes time to switch windows and invoke “lilypond [filename]” every time you want to see a small change done. For me, I like to make sure at least every 1/2 measure or so that what I put in is correct. Thus, switching back and forth between all these windows every 30 seconds makes the whole process very time consuming.

So I wrote a small ruby script (I call it “Autolily”) to automatically invoke lilypond for me:


 1 #!/usr/bin/ruby
 2 #===============================================================================================================#
 3 # Program name: Autolily                                                                                        #
 4 # LICENSE: PUBLIC DOMAIN                                                                                        #
 5 # This program takes 1 argument, the name of a lilypond file (*.ly), and watches it for changes every 1 second. #
 6 # If there has been any change, it simply calls lilypond on it to create a new .pdf/.ps/.midi of it.            #
 7 #                                                                                                               #
 8 # Place this script somewhere, like in ~/scripts                                                                #
 9 # Then, open up a terminal and call it like so: ~/scripts/autolily.rb [lilypond file]                           #
10 # Be sure to call it from the directory where the lilypond file is located -- i.e., don't do [path/to/file]     #
11 #                                                                                                               #
12 # To exit, type CTRL-C.                                                                                         #
13 #===============================================================================================================#
14 
15 if ARGV.size > 0
16     file = ARGV.shift
17     file_data_orig = ""
18     `ls --full-time`.split("\n").each do |line|
19         if line.split(/\s/).last == file
20             file_data_orig = line
21             break
22         end
23     end
24     file_data_new = ""
25 
26     # enter infinite loop -- keep compiling the given lilypond file if it has changed in the past 1 second
27     while true
28         # detect the file size and also timestamp
29         lsarr = `ls --full-time`.split("\n")
30         lsarr.shift # get rid of the first line, since that is the size of all the files in the directory
31 
32         # find our file from ls's output!
33         lsarr.each do |line|
34             if line.split(/\s/).last == file
35                 file_data_new = line
36                 break
37             end
38         end
39 
40         # if there is any change detected, run lilypond on it
41         if file_data_orig != file_data_new
42             puts "\n\e[1;4;38;5;226mAutolily: Change detected in given file; invoking lilypond...\e[0m\n"
43             `lilypond "#{file}"`
44             file_data_orig = file_data_new
45         end
46         sleep 1
47     end
48 else
49     puts "No .ly file specified.\n"
50 end

This very simple script is an infinite loop which takes snapshots of the output of the “ls” command every second, and monitors any changes. Only the output line for the given lilypond file is monitored. Whenever this single line changes (any time you save your file, thus changing the modification timestamp of the file), the “lilypond [filename]” command is invoked to generate the .pdf, .ps, .midi outputs.

Now, every single time you save your .ly file, Autolily will run lilypond for you in the background, letting you know of any errors or such (and if you have evince open on the generated .pdf file, evince will update what your sheet music looks like as well).

Memory usage of this script is a bit embarrassing — I get around 4.5 MB — but hey, that’s Ruby’s fault.

Enjoy!

UPDATE November 12, 2009: Added quotes around _file_ so that a filename with spaces works correctly. (Though, if you’re a Linux guy like me, you probably don’t name your files with spaces in them.)