#!/usr/bin/python3

# Copyright (C) 2006--2026 Brailcom, o.p.s.
#
# Author: Milan Zamazal <pdm@brailcom.org>
#
# This file is part of LilyPond, the GNU music typesetter.
#
# LilyPond is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# LilyPond is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with LilyPond.  If not, see <http://www.gnu.org/licenses/>.

import optparse
import os
import sys

"""

# relocate-preamble.py.in
#
# This file is part of LilyPond, the GNU music typesetter.
#
# Copyright (C) 2007--2026  Han-Wen Nienhuys <hanwen@xs4all.nl>
#
# LilyPond is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# LilyPond is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with LilyPond.  If not, see <http://www.gnu.org/licenses/>.
#

This is generic code, used for all python scripts.

The quotes are to ensure that the source .py file can still be
run as a python script, but does not include any sys.path handling.
Otherwise, the lilypond-book calls inside the build
might modify installed .pyc files.

"""

# This is needed for installations with a non-default layout, ie where share/
# is not next to bin/.
sys.path.insert (0, os.path.join ('/usr/share/lilypond/2.27.0', 'python'))

# Dynamic relocation, for installations with a default layout including GUB,
# but also for execution from the build directory.
bindir = os.path.abspath (os.path.dirname (sys.argv[0]))
topdir = os.path.dirname (bindir)
if bindir.endswith (r'/scripts/out'):
    topdir = os.path.join (os.path.dirname (topdir), 'out')
datadir = os.path.abspath (os.path.join (topdir, 'share', 'lilypond'))
for v in [ 'current', '2.27.0' ]:
    sys.path.insert (0, os.path.join (datadir, v, 'python'))

"""
"""


def process_options(args):
    parser = optparse.OptionParser(version="2.27.0")
    parser.add_option(
        '',
        '--filter-tracks',
        metavar='REGEXP',
        action='store',
        type='string',
        dest='regexp',
        help="display only tracks numbers, of those track names matching REGEXP")
    parser.add_option(
        '',
        '--prefix-tracks',
        metavar='PREFIX',
        action='store',
        type='string',
        dest='prefix',
        help="prefix filtered track numbers with PREFIX")
    parser.add_option('', '--dump', action='store_true', dest='dump',
                      help="just dump parsed contents of the MIDI file")
    parser.add_option(
        '',
        '--pretty',
        action='store_true',
        dest='pretty',
        help="dump parsed contents of the MIDI file in human-readable form (implies --dump)")
    parser.usage = parser.usage + " FILE"
    options, args = parser.parse_args(args)
    if len(args) != 1:
        parser.print_help()
        sys.exit(2)
    return options, args


def read_midi(file):
    import midi
    return midi.parse(open(file, 'rb').read())


def track_info(data):
    tracks = data[1]

    def track_name(track):
        name = ''
        for time, event in track:
            if time > 0:
                break
            if event[0] == 255 and event[1] == 3:
                name = event[2]
                break
        return name
    track_info = []
    for i in range(len(tracks)):
        track_info.append((i, track_name(tracks[i])))
    return track_info


class formatter:
    def __init__(self, txt=""):
        self.text = txt

    def format_vals(self, val1, val2=""):
        return str(val1) + str(val2)

    def format(self, val1, val2=""):
        return self.text + self.format_vals(val1, val2)


class none_formatter (formatter):
    def format_vals(self, val1, val2=""):
        return ''


class meta_formatter (formatter):
    def format_vals(self, val1, val2):
        return str(val2)


class tempo_formatter (formatter):
    def format_vals(self, val1, val2):
        us_per_q = ord(val2[0]) * 65536 + ord(val2[1]) * 256 + ord(val2[2])
        qpm = 60000000 / us_per_q
        return f"{us_per_q} µs/quarter ({qpm:.0f} qpm)"


class time_signature_formatter (formatter):
    def format_vals(self, val1, val2=""):
        from fractions import Fraction
        # if there are more notated 32nd notes per midi quarter than 8,
        # we display a fraction smaller than 1 as scale factor.
        r = Fraction(8, ord(val2[3]))
        if r == 1:
            ratio = ""
        else:
            ratio = " *" + str(r)
        return str(ord(val2[0])) + "/" + str(1 << ord(val2[1])) + ratio \
            + ", metronome " + str(Fraction(ord(val2[2]), 96))


class key_signature_formatter (formatter):
    def format_vals(self, val1, val2):
        key_names = ['F', 'C', 'G', 'D', 'A', 'E', 'B']
        key = (((ord(val2[0]) + 128) % 256) - 128) + ord(val2[1]) * 3 + 1
        return (key_names[key % 7] + (key // 7) * "is" + (-(key // 7)) * "es"
                + " " + ['major', 'minor'][ord(val2[1])])


class channel_formatter (formatter):
    def __init__(self, txt, ch):
        formatter.__init__(self, txt)
        self.channel = ch

    def format(self, val1, val2=""):
        return self.text + "Channel " + str(self.channel) + ", " + \
            self.format_vals(val1, val2)


class control_mode_formatter (formatter):
    def __init__(self, txt, ch):
        formatter.__init__(self, txt)
        self.mode = ch

    def format(self, val1, val2=""):
        return self.text + str(self.mode) + ", " + \
            self.format_vals(val1, val2)


class bend_formatter (channel_formatter):
    def format_vals(self, val1, val2):
        bend = (val2 << 7) + val1
        return ("+" if bend > 0x2000 else "") + str(bend - 0x2000) + \
            " (" + str(bend) + ")"


class note_formatter (channel_formatter):
    def pitch(self, val):
        pitch_names = ['C', 'Cis', 'D', 'Dis', 'E',
                       'F', 'Fis', 'G', 'Gis', 'A', 'Ais', 'B']
        p = val % 12
        oct = val // 12 - 1
        return pitch_names[p] + str(oct) + "(" + str(val) + ")"

    def velocity(self, val):
        return str(val)

    def format_vals(self, val1, val2):
        if val2 > 0:
            return self.pitch(val1) + '@' + self.velocity(val2)
        return self.pitch(val1)


meta_dict = {0x00: meta_formatter("Seq.Nr.:    "),
             0x01: meta_formatter("Text:       "),
             0x02: meta_formatter("Copyright:  "),
             0x03: meta_formatter("Track name: "),
             0x04: meta_formatter("Instrument: "),
             0x05: meta_formatter("Lyric:      "),
             0x06: meta_formatter("Marker:     "),
             0x07: meta_formatter("Cue point:  "),
             0x2F: none_formatter("End of Track"),
             0x51: tempo_formatter("Tempo:      "),
             0x54: meta_formatter("SMPTE Offs.:"),
             0x58: time_signature_formatter("Time signature: "),
             0x59: key_signature_formatter("Key signature: ")
             }

class midi_dumper:
    def __init__(self):
        self.tempo_map = {0: 500000} # midi_clock_time: µs/quarter

    def dump_event(self, ev, time, padding):
        ch = ev[0] & 0x0F
        func = ev[0] & 0xF0
        f = None
        if ev[0] == 0xFF:
            if ev[1] == 0x2F: # end of track
                self.tempo_map[time] = 0
            if ev[1] == 0x51: # set tempo
                us_per_q = ord(ev[2][0]) * 65536 + ord(ev[2][1]) * 256 + ord(ev[2][2])
                self.tempo_map[time] = us_per_q
            f = meta_dict.get(ev[1], formatter())
        if func == 0x80:
            f = note_formatter("Note off: ", ch)
        elif func == 0x90:
            if ev[2] == 0:
                desc = "Note off: "
            else:
                desc = "Note on: "
            f = note_formatter(desc, ch)
        elif func == 0xA0:
            f = note_formatter("Polyphonic aftertouch: ",
                               ch, "Aftertouch pressure: ")
        elif func == 0xB0:
            f = control_mode_formatter("Control mode change: ", ch)
        elif func == 0xC0:
            f = channel_formatter("Program Change: ", ch)
        elif func == 0xD0:
            f = channel_formatter("Channel aftertouch: ", ch)
        elif func == 0xE0:
            f = bend_formatter("Pitch bend: ", ch)
        elif ev[0] in [0xF0, 0xF7]:
            f = meta_formatter("System-exclusive event: ")

        if f:
            if len(ev) > 2:
                print(padding + f.format(ev[1], ev[2]))
            elif len(ev) > 1:
                print(padding + f.format(ev[1]))
            else:
                print(padding + f.format())
        else:
            print(padding + "Unrecognized MIDI event: " + str(ev))

    def dump_midi(self, data, midi_file, options):
        if not options.pretty:
            print(data)
            return
        # First, dump general info, #tracks, etc.
        print("Filename:     " + midi_file)
        i = data[0]
        m_formats = {
            0: 'single multi-channel track',
            1: "one or more simultaneous tracks",
            2: "one or more sequentially independent single-track patterns"}
        print("MIDI format:  " + str(i[0]) + " (" + m_formats.get(i[0], "") + ")")
        ticks_per_w = i[1]
        print("Divisions:    " + str(ticks_per_w) + " per whole note")
        print("#Tracks:      " + str(len(data[1])))
        n = 0
        for tr in data[1]:
            time = 0
            n += 1
            print()
            print("Track " + str(n) + ":")
            print("    Time 0:")
            for ev in tr:
                if ev[0] > time:
                    time = ev[0]
                    print("    Time " + str(time) + ": ")
                self.dump_event(ev[1], time, "        ")
            # The MIDI spec puts tempo events in the first track.
            if n == 1:
                cumul_ticks = 0
                cumul_us = 0
                us_per_q = self.tempo_map[0] # current tempo
                for t in sorted(self.tempo_map):
                    # since last time
                    delta_ticks = t - cumul_ticks
                    delta_q = delta_ticks / (ticks_per_w / 4)
                    delta_us = delta_q * us_per_q
                    # accumulate
                    cumul_us += delta_us
                    # for next time
                    cumul_ticks = t
                    us_per_q = self.tempo_map[t]
                print(f"    Playback time: {cumul_us/1000000:.1f} s")

def go():
    options, args = process_options(sys.argv[1:])
    midi_file = args[0]
    midi_data = read_midi(midi_file)
    info = track_info(midi_data)
    if (options.dump or options.pretty):
        midi_dumper().dump_midi(midi_data, midi_file, options)
    elif options.regexp:
        import re
        regexp = re.compile(options.regexp)
        numbers = [str(n + 1) for n, name in info if regexp.search(name)]
        if numbers:
            if options.prefix:
                sys.stdout.write('%s ' % (options.prefix,))
            sys.stdout.write(','.join(numbers))
            sys.stdout.write('\n')
    else:
        for n, name in info:
            sys.stdout.write('%d %s\n' % (n + 1, name,))


if __name__ == '__main__':
    go()
